Skip to content
Open
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
2 changes: 2 additions & 0 deletions src/ext/hx-sse.js
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,8 @@

// Swap content using the ctx from core (target/swap already resolved)
ctx.text = detail.message.data;
// Always prevent empty swap for SSE - protects against empty data and
// ensures OOB-only messages don't clear target (regardless of allowEmptySwapAfterOOB)
if (!ctx.swap.includes('swapEmpty')) ctx.swap += ' swapEmpty:false';
await htmx.swap(ctx);
delete detail.message.cancelled;
Expand Down
18 changes: 12 additions & 6 deletions src/htmx.js
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,8 @@ var htmx = (() => {
morphScanLimit: 10,
noSwap: [204, 304],
implicitInheritance: false,
defaultSettleDelay: 1
defaultSettleDelay: 1,
allowEmptySwapAfterOOB: false
}
let metaConfig = document.querySelector('meta[name="htmx-config"]');
if (metaConfig) {
Expand Down Expand Up @@ -1268,8 +1269,12 @@ var htmx = (() => {
let partialTasks = this.__processPartials(fragment, ctx);
tasks.push(...oobTasks, ...partialTasks);

// Process main swap first
let mainSwap = this.__processMainSwap(ctx, fragment, partialTasks);
// Determine if empty swap should be prevented
// partials always prevent; oob prevents by default unless config.allowEmptySwapAfterOOB is true
let hasPartials = partialTasks.length || (oobTasks.length && !this.config.allowEmptySwapAfterOOB);

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

__processMainSwap(ctx, fragment, partialTasks) {
__processMainSwap(ctx, fragment, hasPartials) {
// 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
// skip main swap if fragment is empty after partial/oob removal
// swapEmpty modifier can override; default: skip if hasPartials
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 ?? !hasPartials)
) {
if (ctx.select) {
let selected = fragment.querySelectorAll(ctx.select);
Expand Down
6 changes: 5 additions & 1 deletion src/skills/htmx-upgrade-from-htmx2.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,12 +301,16 @@ delete buttons relied on form data:
</form>
```

## Step 12: Handle OOB Swap Order Change
## Step 12: Handle OOB Swap Changes

In htmx 2, OOB swaps happened before the main content swap. In htmx 4, main content swaps first,
then OOB/partial elements swap after. If you have code that depends on OOB elements being present
when the main content is swapped, you may need to restructure.

Additionally, responses containing only OOB elements no longer perform an empty main swap by default.
If your code relied on OOB-only responses clearing the main target, set `htmx.config.allowEmptySwapAfterOOB = true`
or add `swapEmpty:true` to `hx-swap` on specific elements.

## Step 13: Handle Non-200 Response Swapping

In htmx 2, 4xx and 5xx responses did not swap by default. In htmx 4, all responses swap except
Expand Down
52 changes: 52 additions & 0 deletions test/tests/unit/swap.js
Original file line number Diff line number Diff line change
Expand Up @@ -736,6 +736,58 @@ describe('swap() unit tests', function() {
find('#oob').innerText.should.equal('Updated');
})

it('by default (allowEmptySwapAfterOOB:false), oob-only response prevents main swap', async function () {
let original = htmx.config.allowEmptySwapAfterOOB;
htmx.config.allowEmptySwapAfterOOB = false;
try {
createProcessedHTML("<div id='target'>Original</div><div id='oob'>OOB</div>")
await htmx.swap({"target":"#target", "swap":"innerHTML", "text":"<div id='oob' hx-swap-oob='true'>Updated</div>"})
find('#target').innerText.should.equal('Original');
find('#oob').innerText.should.equal('Updated');
} finally {
htmx.config.allowEmptySwapAfterOOB = original;
}
})

it('allowEmptySwapAfterOOB:true allows empty main swap after oob extraction', async function () {
let original = htmx.config.allowEmptySwapAfterOOB;
htmx.config.allowEmptySwapAfterOOB = true;
try {
createProcessedHTML("<div id='target'>Original</div><div id='oob'>OOB</div>")
await htmx.swap({"target":"#target", "swap":"innerHTML", "text":"<div id='oob' hx-swap-oob='true'>Updated</div>"})
find('#target').innerText.should.equal('');
find('#oob').innerText.should.equal('Updated');
} finally {
htmx.config.allowEmptySwapAfterOOB = original;
}
})

it('partials always prevent empty main swap regardless of allowEmptySwapAfterOOB', async function () {
let original = htmx.config.allowEmptySwapAfterOOB;
htmx.config.allowEmptySwapAfterOOB = true;
try {
createProcessedHTML("<div id='target'>Original</div><div id='partial'>Partial</div>")
await htmx.swap({"target":"#target", "swap":"innerHTML", "text":"<hx-partial hx-target='#partial'>Updated</hx-partial>"})
find('#target').innerText.should.equal('Original');
find('#partial').innerText.should.equal('Updated');
} finally {
htmx.config.allowEmptySwapAfterOOB = original;
}
})

it('swapEmpty modifier overrides allowEmptySwapAfterOOB config', async function () {
let original = htmx.config.allowEmptySwapAfterOOB;
htmx.config.allowEmptySwapAfterOOB = true;
try {
createProcessedHTML("<div id='target'>Original</div><div id='oob'>OOB</div>")
await htmx.swap({"target":"#target", "swap":"innerHTML swapEmpty:false", "text":"<div id='oob' hx-swap-oob='true'>Updated</div>"})
find('#target').innerText.should.equal('Original');
find('#oob').innerText.should.equal('Updated');
} finally {
htmx.config.allowEmptySwapAfterOOB = original;
}
})

it('restores focus to textarea after innerHTML swap', async function () {
createProcessedHTML("<textarea id='focused-textarea'>hello world</textarea>")
let textarea = find('#focused-textarea')
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 @@ -1410,7 +1410,7 @@ The modifiers available on `hx-swap` are (parsed as [HCON](#hcon)):
| ignoreTitle | If set to true, any title found in the new content will be ignored and not update the document title |
| strip | true or false, whether to strip the outer element when swapping (unwrap the content) |
| focusScroll | true or false, whether to scroll focused elements into view |
| swapEmpty | true or false, whether to perform the main swap when the response body is empty (`false` skips it). Defaults to [`htmx.config.defaultSwapEmpty`](/reference/config/htmx-config-defaultSwapEmpty) |
| swapEmpty | true or false, whether to perform the main swap when the response body is empty (`false` skips it). Default behavior: skip if partials or OOB swaps were extracted (unless [`htmx.config.allowEmptySwapAfterOOB`](/reference/config/htmx-config-allowEmptySwapAfterOOB) is `true`) |
| scroll | top or bottom, will scroll the target element to its top or bottom |
| show | top or bottom, will scroll the target element's top or bottom into view |
| target | A selector to retarget the swap to a different element |
Expand Down Expand Up @@ -1845,10 +1845,7 @@ 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 and no main content, htmx will **not** perform the main swap. Partials are designed as true response separators — each partial is a self-contained section, giving the server explicit control over multi-target updates.

```html
<!-- Server returns only partials — main target is left untouched -->
Expand All @@ -1860,13 +1857,13 @@ content to swap.
</hx-partial>
```

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

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

Or set the global default via [`htmx.config.defaultSwapEmpty`](/reference/config/htmx-config-defaultSwapEmpty).
[`hx-swap-oob`](/reference/attributes/hx-swap-oob) also prevents empty main swaps by default, but this can be changed globally via [`htmx.config.allowEmptySwapAfterOOB`](/reference/config/htmx-config-allowEmptySwapAfterOOB). Partials always prevent empty swaps regardless of that setting.

#### When to Use Partials

Expand Down
2 changes: 1 addition & 1 deletion www/src/content/reference/01-attributes/07-hx-swap.md
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,7 @@ Use `swapEmpty` to keep the target or clear it:
<div hx-swap="innerHTML swapEmpty">Original</div>
```

Default: [`htmx.config.defaultSwapEmpty`](/reference/config/htmx-config-defaultSwapEmpty)
Default behavior: skip if partials or OOB swaps were extracted (unless [`htmx.config.allowEmptySwapAfterOOB`](/reference/config/htmx-config-allowEmptySwapAfterOOB) is `true`)

## Caveats

Expand Down
22 changes: 4 additions & 18 deletions www/src/content/reference/01-attributes/13-hx-swap-oob.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,29 +142,15 @@ Nested OOB attributes are stripped without swapping.

## Empty Response Behaviour

A response containing only OOB elements still performs an empty main swap.
By default, a response containing only OOB elements will **not** perform an empty main swap.

Use this to remove the main target while updating other elements.
To allow the empty main swap after OOB extraction, set [`htmx.config.allowEmptySwapAfterOOB`](/reference/config/htmx-config-allowEmptySwapAfterOOB) to `true`, or use the [`swapEmpty`](/reference/attributes/hx-swap#swapempty) modifier per-element:

```html
<!-- Server returns this to remove the form and update the list -->
<div id="item-list" hx-swap-oob="beforeend">
<li>New item</li>
</div>
<!-- no main content = form gets swapped with empty fragment, removing it -->
<form hx-post="/submit" hx-swap="outerHTML swapEmpty:true">
```

If you want to prevent the empty main swap, use the [`swapEmpty`](/reference/attributes/hx-swap#swapempty) modifier:

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

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) always prevents empty main swaps regardless of the `allowEmptySwapAfterOOB` setting. Partials are designed as true response separators where the server has explicit control over what gets swapped where.

## 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 |
| [`allowEmptySwapAfterOOB`](/reference/config/htmx-config-allowEmptySwapAfterOOB) | `false` | Allow empty main swap after OOB extraction |
| [`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
@@ -0,0 +1,37 @@
---
title: "htmx.config.allowEmptySwapAfterOOB"
description: "Controls whether OOB swaps prevent empty main swaps"
---

The `htmx.config.allowEmptySwapAfterOOB` option controls whether out-of-band swaps prevent the main swap when the response body is empty after OOB extraction.

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

**Default:** `false`. When `false`, OOB swaps prevent the empty main swap. When `true`, the main swap runs even if only OOB elements were in the response.

Note: [`<hx-partial>`](/reference/tags/hx-partial) elements always prevent empty main swaps regardless of this setting. Partials are designed as true response separators where the server has explicit control.

## Values

- `false` — OOB swaps prevent the empty main swap (default)
- `true` — allow the main swap even after OOB extraction leaves no content

## Example

```javascript
htmx.config.allowEmptySwapAfterOOB = true;
```

```html
<meta name="htmx-config" content='{"allowEmptySwapAfterOOB":true}'>
```

Override per element with the [`swapEmpty`](/reference/attributes/hx-swap#swapempty) modifier on `hx-swap`:

```html
<!-- skip the main swap on an empty response for this element only -->
<div hx-swap="innerHTML swapEmpty:false"></div>

<!-- force the main swap even on an empty response -->
<div hx-swap="innerHTML swapEmpty:true"></div>
```

This file was deleted.

8 changes: 7 additions & 1 deletion www/src/content/reference/06-tags/01-hx-partial.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,13 @@ Avoid targeting an ancestor that the main swap is also replacing — the partial

## 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 (no main content), the main target is left untouched. This is by design — partials are true response separators where each section is self-contained, giving the server explicit control over multi-target updates.

Unlike [`hx-swap-oob`](/reference/attributes/hx-swap-oob), this behavior cannot be changed globally. Use the `swapEmpty:true` modifier on `hx-swap` if you need to clear the main target:

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

## Alternative Syntax

Expand Down
2 changes: 1 addition & 1 deletion www/src/content/reference/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export const EVENT_GROUPS = [
export const CONFIG_GROUPS = [
{ label: 'Core', titles: ['htmx.config', 'htmx.version', 'htmx.config.prefix', 'htmx.config.metaCharacter', 'htmx.config.extensions'] },
{ label: 'Requests', titles: ['htmx.config.defaultTimeout', 'htmx.config.mode'] },
{ label: 'Swaps', titles: ['htmx.config.defaultSwap', 'htmx.config.defaultSwapEmpty', 'htmx.config.noSwap', 'htmx.config.transitions', 'htmx.config.defaultSettleDelay', 'htmx.config.defaultFocusScroll'] },
{ label: 'Swaps', titles: ['htmx.config.defaultSwap', 'htmx.config.allowEmptySwapAfterOOB', 'htmx.config.noSwap', 'htmx.config.transitions', 'htmx.config.defaultSettleDelay', 'htmx.config.defaultFocusScroll'] },
{ label: 'Morphing', titles: ['htmx.config.morphIgnore', 'htmx.config.morphSkip', 'htmx.config.morphSkipChildren', 'htmx.config.morphScanLimit'] },
{ label: 'Indicators', titles: ['htmx.config.includeIndicatorCSS', 'htmx.config.indicatorClass', 'htmx.config.requestClass'] },
{ label: 'Behavior', titles: ['htmx.config.history', 'htmx.config.implicitInheritance', 'htmx.config.inlineScriptNonce', 'htmx.config.logAll'] },
Expand Down
Loading