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
40 changes: 29 additions & 11 deletions src/ext/hx-optimistic.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
(() =>{

// TODO - this needs to be updated to use the new internal API

function normalizeSwapStyle(style) {
return style === 'before' ? 'beforebegin' :
style === 'after' ? 'afterend' :
Expand All @@ -17,43 +15,59 @@
return
}

// TODO - handle inheritance?
let sourceElt = document.querySelector(ctx.optimistic);
if (!sourceElt) return;

let target = ctx.target;
if (!target) return;

if (typeof target === 'string') {
target = document.querySelector(target);
}
if (!target) return;

// Create optimistic div with reset styling
let optimisticDiv = document.createElement('div');
optimisticDiv.style.cssText = 'all: initial';
optimisticDiv.classList.add('hx-optimistic');
optimisticDiv.innerHTML = sourceElt.innerHTML;

// Set data-* for each request param
if (ctx.optimisticBody) {
let keys = new Set(ctx.optimisticBody.keys());
for (let k of keys) {
let values = ctx.optimisticBody.getAll(k).filter(v => typeof v === 'string');
if (!values.length) continue;
let val = values.length === 1 ? values[0] : JSON.stringify(values);
try {
optimisticDiv.dataset[k] = val;
} catch (e) {
try {
optimisticDiv.setAttribute('data-' + k, val);
} catch (e2) { /* truly invalid name, skip */ }
}
}
}

let swapStyle = normalizeSwapStyle(ctx.swap);
ctx.optHidden = [];

if (swapStyle === 'innerHTML') {
// Hide children of target
for (let child of target.children) {
child.style.display = 'none';
ctx.optHidden.push(child)
ctx.optHidden.push(child);
}
target.appendChild(optimisticDiv);
ctx.optimisticDiv = optimisticDiv;
} else if (['beforebegin', 'afterbegin', 'beforeend', 'afterend'].includes(swapStyle)) {
target.insertAdjacentElement(swapStyle, optimisticDiv);
ctx.optimisticDiv = optimisticDiv;
} else {
// Assume outerHTML-like behavior, Hide target and insert div after it
target.style.display = 'none';
ctx.optHidden.push(target)
target.after(optimisticDiv)
ctx.optimisticDiv = optimisticDiv;
ctx.optHidden.push(target);
target.after(optimisticDiv);
}
ctx.optimisticDiv = optimisticDiv;
htmx.process(optimisticDiv);
}

function removeOptimisticContent(ctx) {
Expand All @@ -70,7 +84,11 @@

htmx.registerExtension('hx-optimistic', {
init: (internalAPI) => { api = internalAPI; },
htmx_before_request : (elt, detail) => {
htmx_config_request: (elt, detail) => {
let body = detail.ctx.request.body;
if (body?.entries) detail.ctx.optimisticBody = body;
},
htmx_before_request: (elt, detail) => {
insertOptimisticContent(detail.ctx);
},
htmx_error : (elt, detail) => {
Expand Down
95 changes: 88 additions & 7 deletions test/tests/ext/hx-optimistic.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,31 +103,31 @@ describe('hx-optimistic attribute', function() {
createProcessedHTML('<div id="result">Original</div><div id="opt" style="display:none">Optimistic</div><button hx-post="/submit" hx-target="#result" hx-swap="innerHTML" hx-optimistic="#opt">Go</button>');
find('button').click()
await forRequest()
assert.isNull(document.querySelector('[data-hx-optimistic]'));
assert.isNull(document.querySelector('.hx-optimistic'));
})

it('removes optimistic content on error', async function () {
fetchMock.mockResponse('POST', '/submit', () => Promise.reject(new Error('Network error')));
createProcessedHTML('<div id="result">Original</div><div id="opt" style="display:none">Optimistic</div><button hx-post="/submit" hx-target="#result" hx-swap="innerHTML" hx-optimistic="#opt">Go</button>');
find('button').click()
await waitForEvent('htmx:error', 2000);
assert.isNull(document.querySelector('[data-hx-optimistic]'));
assert.isNull(document.querySelector('.hx-optimistic'));
})

it('unhides hidden elements after swap', async function () {
mockResponse('POST', '/submit', 'Final')
createProcessedHTML('<div id="result"><span id="child">Original</span></div><div id="opt" style="display:none">Optimistic</div><button hx-post="/submit" hx-target="#result" hx-swap="innerHTML" hx-optimistic="#opt">Go</button>');
find('button').click()
await forRequest()
assert.isNull(document.querySelector('[data-hx-oh]'));
assert.equal(find('#result').textContent.trim(), 'Final');
})

it('unhides hidden elements on error', async function () {
fetchMock.mockResponse('POST', '/submit', () => Promise.reject(new Error('Network error')));
createProcessedHTML('<div id="result"><span>Original</span></div><div id="opt" style="display:none">Optimistic</div><button hx-post="/submit" hx-target="#result" hx-swap="innerHTML" hx-optimistic="#opt">Go</button>');
find('button').click()
await waitForEvent('htmx:error', 2000);
assert.isNull(document.querySelector('[data-hx-oh]'));
assert.equal(find('#result span').style.display, '');
})

it('does nothing when optimistic selector not found', async function () {
Expand All @@ -151,7 +151,7 @@ describe('hx-optimistic attribute', function() {
createProcessedHTML('<div id="opt" style="display:none">Optimistic</div><button hx-post="/submit" hx-target="#nonexistent" hx-optimistic="#opt">Go</button>');
find('button').click()
await forRequest()
assert.isNull(document.querySelector('[data-hx-optimistic]'));
assert.isNull(document.querySelector('.hx-optimistic'));
})

it('works when target is resolved from CSS selector', async function () {
Expand All @@ -167,7 +167,7 @@ describe('hx-optimistic attribute', function() {
let optDiv = null;
document.addEventListener('htmx:before:request', function() {
setTimeout(() => {
optDiv = document.querySelector('[data-hx-optimistic]');
optDiv = document.querySelector('.hx-optimistic');
}, 0);
}, {once: true});
createProcessedHTML('<div id="result">Original</div><div id="opt" style="display:none">Optimistic</div><button hx-post="/submit" hx-target="#result" hx-swap="innerHTML" hx-optimistic="#opt">Go</button>');
Expand Down Expand Up @@ -201,7 +201,7 @@ describe('hx-optimistic attribute', function() {
await forRequest()
find('#b2').click()
await forRequest()
assert.isNull(document.querySelector('[data-hx-optimistic]'));
assert.isNull(document.querySelector('.hx-optimistic'));
})

it('works with hx-config override', async function () {
Expand All @@ -227,4 +227,85 @@ describe('hx-optimistic attribute', function() {
await forRequest()
assert.equal(find('#result').textContent.trim(), 'Final');
})

it('sets data-* attributes on optimistic div for each form param', async function () {
mockResponse('POST', '/submit', 'Final')
createProcessedHTML('<form hx-post="/submit" hx-target="#result" hx-swap="innerHTML" hx-optimistic="#opt"><input name="color" value="blue"><input name="size" value="large"><button type="submit">Go</button></form><div id="result">Original</div><div id="opt" style="display:none">content</div>');
let dataColor, dataSize;
document.addEventListener('htmx:before:request', () => {
let el = document.querySelector('.hx-optimistic');
if (el) { dataColor = el.dataset.color; dataSize = el.dataset.size; }
}, {once: true});
find('button').click()
await forRequest()
assert.equal(dataColor, 'blue');
assert.equal(dataSize, 'large');
})

it('sets data-* from hx-vals', async function () {
mockResponse('POST', '/submit', 'Final')
createProcessedHTML('<div id="result">Original</div><div id="opt" style="display:none">content</div><button hx-post="/submit" hx-target="#result" hx-swap="innerHTML" hx-optimistic="#opt" hx-vals=\'{"count": "42"}\'>Go</button>');
let dataCount;
document.addEventListener('htmx:before:request', () => {
let el = document.querySelector('.hx-optimistic');
if (el) dataCount = el.dataset.count;
}, {once: true});
find('button').click()
await forRequest()
assert.equal(dataCount, '42');
})

it('data-* values are safe from XSS via dataset API', async function () {
mockResponse('POST', '/submit', 'Final')
createProcessedHTML('<form hx-post="/submit" hx-target="#result" hx-swap="innerHTML" hx-optimistic="#opt"><input name="name" value="&quot;onclick=alert(1)"><button type="submit">Go</button></form><div id="result">Original</div><div id="opt" style="display:none">x</div>');
let dataName;
document.addEventListener('htmx:before:request', () => {
let el = document.querySelector('.hx-optimistic');
if (el) dataName = el.dataset.name;
}, {once: true});
find('button').click()
await forRequest()
assert.equal(dataName, '"onclick=alert(1)');
})

it('static template content renders without hx-live', async function () {
mockResponse('POST', '/submit', 'Final')
createProcessedHTML('<form hx-post="/submit" hx-target="#result" hx-swap="innerHTML" hx-optimistic="#opt"><input name="x" value="y"><button type="submit">Go</button></form><div id="result">Original</div><div id="opt" style="display:none">Static optimistic</div>');
let optText;
document.addEventListener('htmx:before:request', () => {
let el = document.querySelector('.hx-optimistic');
if (el) optText = el.textContent;
}, {once: true});
find('button').click()
await forRequest()
assert.equal(optText, 'Static optimistic');
})

it('skips file inputs in data-* attributes', async function () {
mockResponse('POST', '/submit', 'Final')
createProcessedHTML('<form hx-post="/submit" hx-target="#result" hx-swap="innerHTML" hx-optimistic="#opt"><input name="title" value="doc"><input name="file" type="file"><button type="submit">Go</button></form><div id="result">Original</div><div id="opt" style="display:none">uploading</div>');
let hasTitle, hasFile;
document.addEventListener('htmx:before:request', () => {
let el = document.querySelector('.hx-optimistic');
if (el) { hasTitle = 'title' in el.dataset; hasFile = 'file' in el.dataset; }
}, {once: true});
find('button').click()
await forRequest()
assert.isTrue(hasTitle);
assert.isFalse(hasFile);
})

it('handles hyphenated form field names via setAttribute', async function () {
mockResponse('POST', '/submit', 'Final')
createProcessedHTML('<form hx-post="/submit" hx-target="#result" hx-swap="innerHTML" hx-optimistic="#opt"><input name="user-name" value="joe"><button type="submit">Go</button></form><div id="result">Original</div><div id="opt" style="display:none">content</div>');
let dataUserName;
document.addEventListener('htmx:before:request', () => {
let el = document.querySelector('.hx-optimistic');
if (el) dataUserName = el.dataset.userName;
}, {once: true});
find('button').click()
await forRequest()
assert.equal(dataUserName, 'joe');
assert.equal(find('#result').textContent.trim(), 'Final');
})
})
108 changes: 106 additions & 2 deletions www/src/content/extensions/06-hx-optimistic.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ icon: "icon-[mdi--lightning-bolt-outline]"
keywords: ["optimistic", "ui", "instant", "updates"]
---

The `optimistic` extension enables optimistic UI updates, applying changes to the DOM immediately before the server responds. If the server request fails, the changes can be rolled back.
The `hx-optimistic` extension shows a preview of the expected result immediately when a request is made, before the server responds. When the response arrives (or on error), the optimistic content is removed and replaced with the real content.

## Installing

Expand All @@ -15,6 +15,110 @@ The `optimistic` extension enables optimistic UI updates, applying changes to th
<script src="https://cdn.jsdelivr.net/npm/htmx.org@next/dist/ext/hx-optimistic.js"></script>
```

Add `hx-optimistic` to your extensions whitelist:

```html
<meta name="htmx-config" content='extensions:"hx-optimistic"'>
```

## Usage

The extension allows you to show the expected result of an action immediately, providing a snappier user experience. The actual server response will replace the optimistic update when it arrives.
Add `hx-optimistic` to any element that makes a request, pointing to a template element:

```html
<ul id="messages">
<li>Hello world</li>
</ul>

<template id="msg-opt">
<li>Sending...</li>
</template>

<form hx-post="/message" hx-target="#messages" hx-swap="beforeend" hx-optimistic="#msg-opt">
<input name="body" placeholder="Message...">
<button type="submit">Send</button>
</form>
```

When the form submits, the template content is immediately inserted into the target. When the server responds, the optimistic content is removed and the real response is swapped in.

## Request Parameters as Data Attributes

The extension captures all string request parameters (form inputs, `hx-vals`, `hx-include`) and sets them as `data-*` attributes on the optimistic element. This makes the submitted values available to CSS and to the [hx-live](/extensions/hx-live) extension.

For a form with `<input name="author" value="You">`, the optimistic div gets `data-author="You"`.

Multi-value fields (checkboxes, multi-selects) are stored as JSON arrays: `data-tags='["js","css"]'`.

## Dynamic Templates with hx-live

When used with the `hx-live` extension, optimistic templates can display the submitted values using `:text` bindings and the `data` proxy:

```html
<template id="msg-opt">
<li><strong :text="data.author"></strong>: <span :text="data.body"></span></li>
</template>

<form hx-post="/message" hx-target="#messages" hx-swap="beforeend" hx-optimistic="#msg-opt">
<input name="author" value="You">
<input name="body" placeholder="Message...">
<button type="submit">Send</button>
</form>
```

The `data` proxy reads `data-*` attributes from the nearest ancestor, and hx-live's `:text` binding sets `textContent` safely (no XSS risk). Full JavaScript expressions are supported:

```html
<template id="order-opt">
<div>
<span :text="data.item"></span>
<span :text="'$' + (data.price * data.qty).toFixed(2)"></span>
<span :text="Array.isArray(data.tags) ? data.tags.join(', ') : data.tags"></span>
</div>
</template>
```

## Styling

The optimistic element receives a `hx-optimistic` class, which you can style with CSS:

```css
.hx-optimistic {
opacity: 0.6;
font-style: italic;
}

.hx-optimistic::after {
content: " (sending...)";
font-size: 0.8em;
color: #888;
}
```

You can also style based on the request parameters:

```css
.hx-optimistic[data-priority="high"] {
border-left: 3px solid red;
}
```

## How It Works

1. On `htmx:config:request` — captures the raw `FormData` before htmx transforms it
2. On `htmx:before:request` — clones the template, sets `data-*` for each param, inserts it into the target (respecting swap style), and calls `htmx.process()` so hx-live bindings activate
3. On `htmx:before:swap` or `htmx:error` — removes the optimistic content and unhides any hidden elements

## Swap Style Behavior

The extension respects the `hx-swap` value:

| Swap Style | Behavior |
|---|---|
| `innerHTML` | Hides target's children, appends optimistic content |
| `beforebegin`, `afterbegin`, `beforeend`, `afterend` | Inserts optimistic content at that position |
| `outerHTML` / other | Hides target, inserts optimistic content after it |

## Without hx-live

The extension works without hx-live — static template content displays as-is, and the `data-*` attributes are still available for CSS selectors. You only need hx-live if you want dynamic `:text` / `:class` / `:style` bindings in the template.