diff --git a/src/ext/hx-sse.js b/src/ext/hx-sse.js index a1c8e148a..2874babc8 100644 --- a/src/ext/hx-sse.js +++ b/src/ext/hx-sse.js @@ -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; diff --git a/src/htmx.js b/src/htmx.js index 024baa612..738faba34 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -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) { @@ -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); } @@ -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); diff --git a/src/skills/htmx-upgrade-from-htmx2.md b/src/skills/htmx-upgrade-from-htmx2.md index 21327c20c..8ca430575 100644 --- a/src/skills/htmx-upgrade-from-htmx2.md +++ b/src/skills/htmx-upgrade-from-htmx2.md @@ -301,12 +301,16 @@ delete buttons relied on form data: ``` -## 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 diff --git a/test/tests/unit/swap.js b/test/tests/unit/swap.js index c509e9930..fee06d969 100644 --- a/test/tests/unit/swap.js +++ b/test/tests/unit/swap.js @@ -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("
Original
OOB
") + await htmx.swap({"target":"#target", "swap":"innerHTML", "text":"
Updated
"}) + 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("
Original
OOB
") + await htmx.swap({"target":"#target", "swap":"innerHTML", "text":"
Updated
"}) + 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("
Original
Partial
") + await htmx.swap({"target":"#target", "swap":"innerHTML", "text":"Updated"}) + 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("
Original
OOB
") + await htmx.swap({"target":"#target", "swap":"innerHTML swapEmpty:false", "text":"
Updated
"}) + 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("") let textarea = find('#focused-textarea') diff --git a/www/src/content/docs.mdx b/www/src/content/docs.mdx index 6cd375d21..02623456f 100644 --- a/www/src/content/docs.mdx +++ b/www/src/content/docs.mdx @@ -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 | @@ -1845,10 +1845,7 @@ You can use the equivalent <template> form: <templat #### Empty Response Behaviour -When a response contains only `` 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 `` 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 @@ -1860,13 +1857,13 @@ content to swap. ``` -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 ``` -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 diff --git a/www/src/content/reference/01-attributes/07-hx-swap.md b/www/src/content/reference/01-attributes/07-hx-swap.md index fd4f80153..b51f91a70 100644 --- a/www/src/content/reference/01-attributes/07-hx-swap.md +++ b/www/src/content/reference/01-attributes/07-hx-swap.md @@ -351,7 +351,7 @@ Use `swapEmpty` to keep the target or clear it:
Original
``` -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 diff --git a/www/src/content/reference/01-attributes/13-hx-swap-oob.md b/www/src/content/reference/01-attributes/13-hx-swap-oob.md index 25e7e4a44..fe18984c8 100644 --- a/www/src/content/reference/01-attributes/13-hx-swap-oob.md +++ b/www/src/content/reference/01-attributes/13-hx-swap-oob.md @@ -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 - -
-
  • New item
  • -
    - +
    ``` -If you want to prevent the empty main swap, use the [`swapEmpty`](/reference/attributes/hx-swap#swapempty) modifier: - -```html - -``` - -Or set the global default via [`htmx.config.defaultSwapEmpty`](/reference/config/htmx-config-defaultSwapEmpty). - -[``](/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. +[``](/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 diff --git a/www/src/content/reference/04-config/01-htmx-config.md b/www/src/content/reference/04-config/01-htmx-config.md index 9d10d1015..a67b20596 100644 --- a/www/src/content/reference/04-config/01-htmx-config.md +++ b/www/src/content/reference/04-config/01-htmx-config.md @@ -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 `` 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 | diff --git a/www/src/content/reference/04-config/26-htmx-config-allowEmptySwapAfterOOB.md b/www/src/content/reference/04-config/26-htmx-config-allowEmptySwapAfterOOB.md new file mode 100644 index 000000000..d4e2941b4 --- /dev/null +++ b/www/src/content/reference/04-config/26-htmx-config-allowEmptySwapAfterOOB.md @@ -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: [``](/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 + +``` + +Override per element with the [`swapEmpty`](/reference/attributes/hx-swap#swapempty) modifier on `hx-swap`: + +```html + +
    + + +
    +``` diff --git a/www/src/content/reference/04-config/26-htmx-config-defaultSwapEmpty.md b/www/src/content/reference/04-config/26-htmx-config-defaultSwapEmpty.md deleted file mode 100644 index 88b2cafaf..000000000 --- a/www/src/content/reference/04-config/26-htmx-config-defaultSwapEmpty.md +++ /dev/null @@ -1,35 +0,0 @@ ---- -title: "htmx.config.defaultSwapEmpty" -description: "Controls main swap when response body is empty" ---- - -The `htmx.config.defaultSwapEmpty` option controls the main swap when the response body is empty. - -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 `` elements. - -## Values - -- `true` — perform the main swap on an empty response (clears the target) -- `false` — skip the main swap on an empty response (leaves the target unchanged) - -## Example - -```javascript -htmx.config.defaultSwapEmpty = false; -``` - -```html - -``` - -Override per element with the [`swapEmpty`](/reference/attributes/hx-swap#swapempty) modifier on `hx-swap`: - -```html - -
    - - -
    -``` diff --git a/www/src/content/reference/06-tags/01-hx-partial.md b/www/src/content/reference/06-tags/01-hx-partial.md index 90dd6bff3..33621d67b 100644 --- a/www/src/content/reference/06-tags/01-hx-partial.md +++ b/www/src/content/reference/06-tags/01-hx-partial.md @@ -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 `` 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 `` 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 + +``` ## Alternative Syntax diff --git a/www/src/content/reference/index.mdx b/www/src/content/reference/index.mdx index d60814777..21957bf13 100644 --- a/www/src/content/reference/index.mdx +++ b/www/src/content/reference/index.mdx @@ -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'] },