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
17 changes: 15 additions & 2 deletions src/ext/hx-preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,29 @@
let form = elt.form || elt.closest("form");
let body = api.collectFormData(elt, form, evt.submitter);
let valsResult = api.getAttributeObject(elt, 'hx-vals', obj => {
ctx.vals = obj; // make available for json extensions
for (let key in obj) body.set(key, obj[key]);
});
}, {ctx});
if (valsResult) await valsResult;

// Apply hx-headers and fire htmx:config:request here, as a triggered request does,
// so the preloaded response matches the request the click would have sent
let headersResult = api.getAttributeObject(elt, 'hx-headers', obj => {
for (let key in obj) ctx.request.headers[key] = String(obj[key]);
}, {ctx});
if (headersResult) await headersResult;

Object.assign(ctx.request, {form, submitter: evt.submitter, body});
ctx.request.headers["HX-Preloaded"] = "true";
if (!api.triggerHtmxEvent(elt, "htmx:config:request", {ctx})) return;

ctx.request.headers["HX-Request-Type"] = (ctx.target === document.body || ctx.select) ? "full" : "partial";

let action = ctx.request.action.replace?.(/#.*$/, '');

let params = new URLSearchParams(body);
let params = new URLSearchParams(ctx.request.body);
if (params.size) action += (/\?/.test(action) ? "&" : "?") + params;
ctx.request.body = null;

let url = new URL(action, location.href);
elt._htmx.preload = {
Expand Down
1 change: 1 addition & 0 deletions src/ext/hx-prompt.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
htmx_config_request(elt, {ctx}) {
let question = api.attributeValue(ctx.sourceElement, 'hx-prompt');
if (question == null) return;
if (ctx.request.headers['HX-Preloaded']) return false;
let answer = (window.htmxPrompt || window.prompt)(question);
if (answer === null) return false;
if (!htmx.trigger(ctx.sourceElement, 'htmx:prompt', { prompt: answer, target: ctx.target })) return false;
Expand Down
67 changes: 67 additions & 0 deletions test/tests/ext/hx-preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,73 @@ describe('hx-preload attribute', function() {
assert.equal(lastFetch().request.headers['HX-Request-Type'], 'partial')
})

it('sends inherited hx-headers and HX-Preloaded with the preload', async function () {
mockResponse('GET', '/test', 'Response')
let div = createProcessedHTML('<div hx-headers:inherited=\'{"X-Inherited":"1"}\'><button hx-get="/test" hx-preload="mouseenter">Click</button></div>');
let btn = div.querySelector('button')
btn.dispatchEvent(new Event('mouseenter'))
await htmx.timeout(20)
assert.equal(lastFetch().request.headers['X-Inherited'], '1')
assert.equal(lastFetch().request.headers['HX-Preloaded'], 'true')
})

it('applies htmx:config:request changes to the preload', async function () {
mockResponse('GET', '/test', 'Response')
let listener = (evt) => {
evt.detail.ctx.request.headers['X-Configured'] = '1'
evt.detail.ctx.request.body.set('q', '1')
}
document.addEventListener('htmx:config:request', listener)
try {
let btn = createProcessedHTML('<button hx-get="/test" hx-preload="mouseenter">Click</button>');
btn.dispatchEvent(new Event('mouseenter'))
await htmx.timeout(20)
assert.equal(lastFetch().request.headers['X-Configured'], '1')
assert.equal(lastFetch().url, '/test?q=1')
assert.isNull(lastFetch().request.body)
} finally {
document.removeEventListener('htmx:config:request', listener)
}
})

it('lets an htmx:config:request listener tell a preload from the click', async function () {
let fetchCount = 0
mockResponse('GET', '/test', () => { fetchCount++; return 'Response'; })
let seen = []
let listener = (evt) => {
let preloaded = evt.detail.ctx.request.headers['HX-Preloaded']
seen.push(preloaded)
if (preloaded) evt.preventDefault()
}
document.addEventListener('htmx:config:request', listener)
try {
let btn = createProcessedHTML('<button hx-get="/test" hx-preload="mouseenter">Click</button>');
btn.dispatchEvent(new Event('mouseenter'))
await htmx.timeout(20)
assert.equal(fetchCount, 0, 'preload should be cancelled')
btn.click()
await htmx.timeout(20)
assert.equal(fetchCount, 1, 'click should fetch')
assert.deepEqual(seen, ['true', undefined])
assert.isUndefined(lastFetch().request.headers['HX-Preloaded'])
} finally {
document.removeEventListener('htmx:config:request', listener)
}
})

it('reuses preload containing hx-headers for the click', async function () {
let fetchCount = 0;
mockResponse('GET', '/test', () => { fetchCount++; return 'Response'; })
let btn = createProcessedHTML('<button hx-get="/test" hx-headers=\'{"X-Headers":"1"}\' hx-preload="mouseenter">Click</button>');
btn.dispatchEvent(new Event('mouseenter'))
await htmx.timeout(20)
assert.equal(fetchCount, 1, 'preload should fetch')
btn.click()
await htmx.timeout(20)
assert.equal(fetchCount, 1, 'click should reuse preload, not fetch again')
assert.equal(lastFetch().request.headers['X-Headers'], '1')
})

it('reuses preload with URL containing fragment', async function () {
let fetchCount = 0;
mockResponse('GET', '/test', () => { fetchCount++; return 'Response'; })
Expand Down
30 changes: 30 additions & 0 deletions test/tests/ext/hx-prompt.js
Original file line number Diff line number Diff line change
Expand Up @@ -228,4 +228,34 @@ describe('hx-prompt extension', function() {
assert.isUndefined(fetchMock.getLastCall());
});
});

describe('with hx-preload', () => {
before(async () => {
let script = document.createElement('script');
script.src = '../src/ext/hx-preload.js';
await new Promise(resolve => {
script.onload = resolve;
document.head.appendChild(script);
});
});

it('skips the preload and prompts once, on the click', async function() {
let prompts = 0;
window.htmxPrompt = () => { prompts++; return 'answer'; };
let fetchCount = 0;
mockResponse('GET', '/items/1', () => { fetchCount++; return 'ok'; });

let btn = createProcessedHTML('<button hx-get="/items/1" hx-prompt="Q?" hx-preload="mouseenter">Go</button>');
btn.dispatchEvent(new Event('mouseenter'));
await htmx.timeout(20);
assert.equal(prompts, 0, 'preload should not prompt');
assert.equal(fetchCount, 0, 'preload should be cancelled');

btn.click();
await forRequest();
assert.equal(prompts, 1);
assert.equal(fetchCount, 1);
assert.equal(lastFetch().request.headers['HX-Prompt'], 'answer');
});
});
});
2 changes: 2 additions & 0 deletions www/src/content/extensions/05-hx-preload.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ Add an `hx-preload` attribute to any boosted hyperlinks and [`hx-get`](/referenc

All preload requests include an additional `HX-Preloaded: true` header.

A preload applies [`hx-headers`](/reference/attributes/hx-headers) and fires [`htmx:config:request`](/reference/events/htmx-config-request) before it is sent, so headers added there reach the server even when the click reuses the response. The click fires the event again; `HX-Preloaded` is already set on the preload's request, so a listener can tell the two apart and call `preventDefault()` to cancel only the preload.

## hx-boost Integration

When the preload extension is loaded, all [`hx-boost`](/reference/attributes/hx-boost) anchor tags are automatically preloaded on `mousedown` without needing an explicit `hx-preload` attribute. To opt out of this behaviour, set `htmx.config.preload.autoBoost = false`.
Expand Down
4 changes: 4 additions & 0 deletions www/src/content/extensions/16-hx-prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ The `hx-prompt` attribute supports inheritance.

If the user cancels the prompt, the confirm dialog is not shown.

### Composing with hx-preload

An [`hx-preload`](/extensions/hx-preload) request never prompts. The extension cancels the preload of an element with `hx-prompt`, so the prompt opens once, on the click, and the click sends its own request.

### Custom Dialogs

Assign a function to `window.htmxPrompt` to use a custom (synchronous) dialog.
Expand Down