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
52 changes: 30 additions & 22 deletions src/htmx.js
Original file line number Diff line number Diff line change
Expand Up @@ -1186,7 +1186,7 @@ var htmx = (() => {
tasks.push(...oobTasks, ...partialTasks);

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

__processMainSwap(ctx, fragment, partialTasks) {
// Create main task if needed
/**
* @param {object} ctx - request context
* @param {DocumentFragment} fragment - response content (after extraction)
* @param {Array} extractedTasks - swap tasks from hx-swap-oob and <hx-partial>
*/
__processMainSwap(ctx, fragment, extractedTasks) {
let swapSpec = this.__parseSwapSpec(ctx.swap || this.config.defaultSwap);
// skip creating main swap if extracting partials resulted in empty response except for delete style
if (swapSpec.style === 'delete' || fragment.childElementCount > 0 || /\S/.test(fragment.textContent) || !partialTasks.length) {
if (ctx.select) {
let selected = fragment.querySelectorAll(ctx.select);
fragment = document.createDocumentFragment();
fragment.append(...selected);
}
if (this.__isBoosted(ctx.sourceElement)) {
swapSpec.show ||= 'top';
}
let mainSwap = {
type: 'main',
fragment,
target: this.__resolveTarget(ctx.sourceElement || document.body, swapSpec.target || ctx.target),
swapSpec,
sourceElement: ctx.sourceElement,
transition: ctx.transition && swapSpec.transition !== false
};
return mainSwap;

let hasElements = fragment.childElementCount > 0;
let hasText = /\S/.test(fragment.textContent);
let allContentWasExtracted = !hasElements && !hasText && extractedTasks.length > 0;

// delete always proceeds (intentional target removal)
if (allContentWasExtracted && swapSpec.style !== 'delete') return;

if (ctx.select) { // hx-select / HX-Reselect
let selected = fragment.querySelectorAll(ctx.select);
fragment = document.createDocumentFragment();
fragment.append(...selected);
}
if (this.__isBoosted(ctx.sourceElement)) {
swapSpec.show ||= 'top';
}
return {
type: 'main',
fragment,
target: this.__resolveTarget(ctx.sourceElement || document.body, swapSpec.target || ctx.target),
swapSpec,
sourceElement: ctx.sourceElement,
transition: ctx.transition && swapSpec.transition !== false
};
}

async __insertContent(task, cssTransition = true) {
Expand Down
14 changes: 14 additions & 0 deletions test/tests/unit/swap.js
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,20 @@ describe('swap() unit tests', function() {
find('#d2').innerText.should.equal("OOB");
})

it('does not swap main target when response contains only hx-swap-oob elements', async function () {
createProcessedHTML("<div id='d1'>Original</div><div id='d2'>Old</div>")
await htmx.swap({"target":"#d1", "text":"<div id='d2' hx-swap-oob='true'>OOB Updated</div>"})
find('#d1').innerText.should.equal("Original");
find('#d2').innerText.should.equal("OOB Updated");
})

it('does not swap main target when response contains only hx-swap-oob with whitespace', async function () {
createProcessedHTML("<div id='d1'>Original</div><div id='d2'>Old</div>")
await htmx.swap({"target":"#d1", "text":"\n <div id='d2' hx-swap-oob='true'>OOB Updated</div> \n"})
find('#d1').innerText.should.equal("Original");
find('#d2').innerText.should.equal("OOB Updated");
})

it('swaps oob with innerHTML', async function () {
createProcessedHTML("<div id='d1'></div><div id='d2'><span>Old</span></div>")
await htmx.swap({"target":"#d1", "text":"<div>Main</div><div id='d2' hx-swap-oob='innerHTML'>OOB</div>"})
Expand Down
15 changes: 15 additions & 0 deletions www/src/content/docs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1870,6 +1870,21 @@ Both methods work together. Use them in the same response if needed.

**Use partial tags** for everything else.

#### Responses Without Main Content

When a response contains only [`hx-swap-oob`](/reference/attributes/hx-swap-oob) or [`<hx-partial>`](/reference/tags/hx-partial) elements (no main content),
the main target is left untouched.

```html
<!-- Only updates #notifications, the main target is not modified -->
<div id="notifications" hx-swap-oob="true">
3 new messages
</div>
```

This is useful for SSE and WebSocket connections where every message targets specific
elements and there is no main swap.

### Additional Features

#### Select Specific Elements for OOB
Expand Down
7 changes: 6 additions & 1 deletion www/src/content/reference/01-attributes/13-hx-swap-oob.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,11 @@ This behavior can be changed by setting the config `htmx.config.allowNestedOobSw
is `false`, OOB swaps are only processed when the element is *adjacent to* the main response element, OOB swaps
elsewhere will be ignored and oob-swap-related attributes stripped.

## Responses Without Main Content

When a response contains only `hx-swap-oob` elements, the main target is not modified.
See [Multi-Target Updates](/docs#choosing-between-them) for details.

## See Also

- [`<hx-partial>`](/docs#partials-hx-partial), an alternative for multi-target updates with explicit control over targeting and swap strategy
- [`<hx-partial>`](/reference/tags/hx-partial), an alternative for multi-target updates with explicit control over targeting and swap strategy
45 changes: 45 additions & 0 deletions www/src/content/reference/06-tags/01-hx-partial.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
y---
title: "<hx-partial>"
description: "Target multiple elements from a single response"
---

The `<hx-partial>` tag lets you update multiple elements from a single response, with explicit control over targeting and swap strategy.

## Syntax

```html
<hx-partial hx-target="#messages" hx-swap="beforeend">
<div>New message</div>
</hx-partial>

<hx-partial hx-target="#count">
<span>5</span>
</hx-partial>
```

## Attributes

- [`hx-target`](/reference/attributes/hx-target) - CSS selector for where to place content
- `id` - Shorthand alternative to `hx-target`. Targets the element with that ID (e.g. `<hx-partial id="messages">` targets `#messages`)
- [`hx-swap`](/reference/attributes/hx-swap) - Swap style (defaults to `innerHTML`)

Either `hx-target` or `id` is required. If both are present, `hx-target` takes precedence.

## 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.

## Alternative Syntax

Template languages that strip unknown tags can use the equivalent `<template>` form:

```html
<template hx type="partial" hx-target="#messages" hx-swap="beforeend">
<div>New message</div>
</template>
```

## See Also

- [`hx-swap-oob`](/reference/attributes/hx-swap-oob), an alternative for simple ID-based updates
- [Multi-Target Updates](/docs#multi-target-updates) for full documentation