From 0298d7c98867561c76a81fab092156a9888b5173 Mon Sep 17 00:00:00 2001 From: MichaelWest22 Date: Tue, 15 Jul 2025 15:57:25 +1200 Subject: [PATCH 01/10] add pushUrl option --- src/htmx.js | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/htmx.js b/src/htmx.js index 4c8203ef2..017052da3 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -4053,7 +4053,8 @@ var htmx = (function() { targetOverride: resolvedTarget, swapOverride: context.swap, select: context.select, - returnPromise: true + returnPromise: true, + pushUrl: context.pushUrl }) } } else { @@ -4706,6 +4707,11 @@ var htmx = (function() { type: saveType, path } + } else if (responseInfo.etc.pushUrl) { + return { + type: 'push', + path: responsePath || requestPath + } } else { return {} } @@ -4790,17 +4796,16 @@ var htmx = (function() { if (hasHeader(xhr, /HX-Location:/i)) { saveCurrentPageToHistory() let redirectPath = xhr.getResponseHeader('HX-Location') - /** @type {HtmxAjaxHelperContext&{path:string}} */ - var redirectSwapSpec + /** @type {HtmxAjaxHelperContext&{path?:string}} */ + var redirectSwapSpec = { pushUrl: true } if (redirectPath.indexOf('{') === 0) { redirectSwapSpec = parseJSON(redirectPath) // what's the best way to throw an error if the user didn't include this redirectPath = redirectSwapSpec.path delete redirectSwapSpec.path + redirectSwapSpec.pushUrl = true } - ajaxHelper('get', redirectPath, redirectSwapSpec).then(function() { - pushUrlIntoHistory(redirectPath) - }) + ajaxHelper('get', redirectPath, redirectSwapSpec) return } @@ -5216,6 +5221,7 @@ var htmx = (function() { * @property {Object|FormData} [values] * @property {Record} [headers] * @property {string} [select] + * @property {boolean} [pushUrl] */ /** @@ -5262,6 +5268,7 @@ var htmx = (function() { * @property {Object|FormData} [values] * @property {boolean} [credentials] * @property {number} [timeout] + * @property {boolean} [pushUrl] */ /** From fb7761ecb4a68149fb53541727863f996c7d8978 Mon Sep 17 00:00:00 2001 From: MichaelWest22 Date: Sat, 26 Jul 2025 01:13:11 +1200 Subject: [PATCH 02/10] Remove duplicate save to history --- src/htmx.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/htmx.js b/src/htmx.js index 017052da3..89c0eea1f 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -4794,7 +4794,6 @@ var htmx = (function() { } if (hasHeader(xhr, /HX-Location:/i)) { - saveCurrentPageToHistory() let redirectPath = xhr.getResponseHeader('HX-Location') /** @type {HtmxAjaxHelperContext&{path?:string}} */ var redirectSwapSpec = { pushUrl: true } From c6a0c19153c7a8684f5032a405fc14a1db4bc659 Mon Sep 17 00:00:00 2001 From: MichaelWest22 Date: Tue, 5 Aug 2025 01:41:10 +1200 Subject: [PATCH 03/10] Improve pushUrl and hx-location url handling --- src/htmx.js | 15 +++------ test/core/api.js | 30 ++++++++++++++++++ test/core/headers.js | 50 ++++++++++++++++++++++++++++++ www/content/api.md | 1 + www/content/headers/hx-location.md | 1 + 5 files changed, 87 insertions(+), 10 deletions(-) diff --git a/src/htmx.js b/src/htmx.js index 89c0eea1f..9dfecdaec 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -4669,7 +4669,7 @@ var htmx = (function() { const requestPath = responseInfo.pathInfo.finalRequestPath const responsePath = responseInfo.pathInfo.responsePath - const pushUrl = getClosestAttributeValue(elt, 'hx-push-url') + const pushUrl = getClosestAttributeValue(elt, 'hx-push-url') || responseInfo.etc.pushUrl const replaceUrl = getClosestAttributeValue(elt, 'hx-replace-url') const elementIsBoosted = getInternalData(elt).boosted @@ -4707,11 +4707,6 @@ var htmx = (function() { type: saveType, path } - } else if (responseInfo.etc.pushUrl) { - return { - type: 'push', - path: responsePath || requestPath - } } else { return {} } @@ -4796,14 +4791,14 @@ var htmx = (function() { if (hasHeader(xhr, /HX-Location:/i)) { let redirectPath = xhr.getResponseHeader('HX-Location') /** @type {HtmxAjaxHelperContext&{path?:string}} */ - var redirectSwapSpec = { pushUrl: true } + var redirectSwapSpec = {} if (redirectPath.indexOf('{') === 0) { redirectSwapSpec = parseJSON(redirectPath) // what's the best way to throw an error if the user didn't include this redirectPath = redirectSwapSpec.path delete redirectSwapSpec.path - redirectSwapSpec.pushUrl = true } + redirectSwapSpec.pushUrl = redirectSwapSpec.pushUrl || 'true' ajaxHelper('get', redirectPath, redirectSwapSpec) return } @@ -5220,7 +5215,7 @@ var htmx = (function() { * @property {Object|FormData} [values] * @property {Record} [headers] * @property {string} [select] - * @property {boolean} [pushUrl] + * @property {string} [pushUrl] */ /** @@ -5267,7 +5262,7 @@ var htmx = (function() { * @property {Object|FormData} [values] * @property {boolean} [credentials] * @property {number} [timeout] - * @property {boolean} [pushUrl] + * @property {string} [pushUrl] */ /** diff --git a/test/core/api.js b/test/core/api.js index be4627b55..94acfc041 100644 --- a/test/core/api.js +++ b/test/core/api.js @@ -656,4 +656,34 @@ describe('Core htmx API test', function() { var div = make('
textNode
') htmx.process(div.firstChild) }) + + it('ajax api pushUrl should push an element into the cache when true', function() { + this.server.respondWith('POST', '/test123', 'Clicked!') + + var div = make("
") + htmx.ajax('POST', '/test123', { + target: '#d1', + swap: 'innerHTML', + pushUrl: 'true' + }) + this.server.respond() + div.innerHTML.should.equal('Clicked!') + var path = sessionStorage.getItem('htmx-current-path-for-history') + path.should.equal('/test123') + }) + + it('ajax api pushUrl should push an element into the cache when string', function() { + this.server.respondWith('POST', '/test', 'Clicked!') + + var div = make("
") + htmx.ajax('POST', '/test', { + target: '#d1', + swap: 'innerHTML', + pushUrl: '/abc123' + }) + this.server.respond() + div.innerHTML.should.equal('Clicked!') + var path = sessionStorage.getItem('htmx-current-path-for-history') + path.should.equal('/abc123') + }) }) diff --git a/test/core/headers.js b/test/core/headers.js index a91535a11..6c4f1975f 100644 --- a/test/core/headers.js +++ b/test/core/headers.js @@ -410,6 +410,56 @@ describe('Core htmx AJAX headers', function() { }, 30) }) + it('should push new Url on HX-Location', function(done) { + sessionStorage.removeItem('htmx-current-path-for-history') + this.server.respondWith('GET', '/test', [200, { 'HX-Location': '{"path":"/test2", "target":"#work-area"}' }, '']) + this.server.respondWith('GET', '/test2', [200, {}, '
Yay! Welcome
']) + var div = make('
') + div.click() + this.server.respond() + this.server.respond() + setTimeout(function() { + getWorkArea().innerHTML.should.equal('
Yay! Welcome
') + var path = sessionStorage.getItem('htmx-current-path-for-history') + path.should.equal('/test2') + done() + }, 30) + }) + + it('should not push new Url on HX-Location if pushUrl false', function(done) { + sessionStorage.setItem('htmx-current-path-for-history', '/old') + this.server.respondWith('GET', '/test', [200, { 'HX-Location': '{"pushUrl":"false", "path":"/test2", "target":"#work-area"}' }, '']) + this.server.respondWith('GET', '/test2', [200, {}, '
Yay! Welcome
']) + var div = make('
') + div.click() + this.server.respond() + this.server.respond() + setTimeout(function() { + getWorkArea().innerHTML.should.equal('
Yay! Welcome
') + var path = sessionStorage.getItem('htmx-current-path-for-history') + path.should.equal('/old') + done() + }, 30) + }) + + it('should push different Url on HX-Location if pushUrl is string', function(done) { + sessionStorage.removeItem('htmx-current-path-for-history') + var HTMX_HISTORY_CACHE_NAME = 'htmx-history-cache' + sessionStorage.removeItem(HTMX_HISTORY_CACHE_NAME) + this.server.respondWith('GET', '/test', [200, { 'HX-Location': '{"pushUrl":"/abc123", "path":"/test2", "target":"#work-area"}' }, '']) + this.server.respondWith('GET', '/test2', [200, {}, '
Yay! Welcome
']) + var div = make('
') + div.click() + this.server.respond() + this.server.respond() + setTimeout(function() { + getWorkArea().innerHTML.should.equal('
Yay! Welcome
') + var path = sessionStorage.getItem('htmx-current-path-for-history') + path.should.equal('/abc123') + done() + }, 30) + }) + it('should refresh page on HX-Refresh', function() { var refresh = false htmx.location = { reload: function() { refresh = true } } diff --git a/www/content/api.md b/www/content/api.md index d72c59746..326ef491c 100644 --- a/www/content/api.md +++ b/www/content/api.md @@ -65,6 +65,7 @@ or * `values` - values to submit with the request * `headers` - headers to submit with the request * `select` - allows you to select the content you want swapped from a response + * `pushUrl` - can be `'true'` or a path to push a URL into browser location history ##### Example diff --git a/www/content/headers/hx-location.md b/www/content/headers/hx-location.md index afe67a817..84e8bb83c 100644 --- a/www/content/headers/hx-location.md +++ b/www/content/headers/hx-location.md @@ -30,6 +30,7 @@ Path is required and is url to load the response from. The rest of the data mirr * `values` - values to submit with the request * `headers` - headers to submit with the request * `select` - allows you to select the content you want swapped from a response +* `pushUrl` - set to `'false'` or a path string to prevent or override the URL pushed to browser location history ## Notes From c7fba538de754affcaeae5429328ba7c93171962 Mon Sep 17 00:00:00 2001 From: MichaelWest22 Date: Tue, 5 Aug 2025 16:15:19 +1200 Subject: [PATCH 04/10] Add replace option to api as well --- src/htmx.js | 102 +++++++++-------------------- test/core/api.js | 38 +++++++++-- test/core/headers.js | 8 +-- www/content/api.md | 3 +- www/content/headers/hx-location.md | 3 +- 5 files changed, 73 insertions(+), 81 deletions(-) diff --git a/src/htmx.js b/src/htmx.js index 9dfecdaec..e35906153 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -4054,7 +4054,8 @@ var htmx = (function() { swapOverride: context.swap, select: context.select, returnPromise: true, - pushUrl: context.pushUrl + push: context.push, + replace: context.replace }) } } else { @@ -4633,82 +4634,39 @@ var htmx = (function() { * @return {HtmxHistoryUpdate} */ function determineHistoryUpdates(elt, responseInfo) { - const xhr = responseInfo.xhr + const { xhr, pathInfo, etc } = responseInfo - //= ========================================== - // First consult response headers - //= ========================================== - let pathFromHeaders = null - let typeFromHeaders = null - if (hasHeader(xhr, /HX-Push:/i)) { - pathFromHeaders = xhr.getResponseHeader('HX-Push') - typeFromHeaders = 'push' - } else if (hasHeader(xhr, /HX-Push-Url:/i)) { - pathFromHeaders = xhr.getResponseHeader('HX-Push-Url') - typeFromHeaders = 'push' - } else if (hasHeader(xhr, /HX-Replace-Url:/i)) { - pathFromHeaders = xhr.getResponseHeader('HX-Replace-Url') - typeFromHeaders = 'replace' - } + let push = xhr.getResponseHeader('HX-Push') || xhr.getResponseHeader('HX-Push-Url') + let replace = xhr.getResponseHeader('HX-Replace-Url') // if there was a response header, that has priority - if (pathFromHeaders) { - if (pathFromHeaders === 'false') { - return {} - } else { - return { - type: typeFromHeaders, - path: pathFromHeaders - } + if (!push && !replace) { + // Next resolve via DOM values + push = getClosestAttributeValue(elt, 'hx-push-url') || etc.push + replace = getClosestAttributeValue(elt, 'hx-replace-url') || etc.replace + if (!push && !replace && getInternalData(elt).boosted) { + push = 'true' } } - //= ========================================== - // Next resolve via DOM values - //= ========================================== - const requestPath = responseInfo.pathInfo.finalRequestPath - const responsePath = responseInfo.pathInfo.responsePath - - const pushUrl = getClosestAttributeValue(elt, 'hx-push-url') || responseInfo.etc.pushUrl - const replaceUrl = getClosestAttributeValue(elt, 'hx-replace-url') - const elementIsBoosted = getInternalData(elt).boosted - - let saveType = null - let path = null - - if (pushUrl) { - saveType = 'push' - path = pushUrl - } else if (replaceUrl) { - saveType = 'replace' - path = replaceUrl - } else if (elementIsBoosted) { - saveType = 'push' - path = responsePath || requestPath // if there is no response path, go with the original request path + let path = push || replace + // unset or false indicates no push, return empty object + if (!path || path === 'false') { + return {} } - if (path) { - // false indicates no push, return empty object - if (path === 'false') { - return {} - } - - // true indicates we want to follow wherever the server ended up sending us - if (path === 'true') { - path = responsePath || requestPath // if there is no response path, go with the original request path - } - - // restore any anchor associated with the request - if (responseInfo.pathInfo.anchor && path.indexOf('#') === -1) { - path = path + '#' + responseInfo.pathInfo.anchor - } + // true indicates we want to follow wherever the server ended up sending us + if (path === 'true') { + path = pathInfo.responsePath || pathInfo.finalRequestPath // if there is no response path, go with the original request path + } + // restore any anchor associated with the request + if (pathInfo.anchor && path.indexOf('#') === -1) { + path = path + '#' + pathInfo.anchor + } - return { - type: saveType, - path - } - } else { - return {} + return { + type: push ? 'push' : 'replace', + path } } @@ -4798,7 +4756,7 @@ var htmx = (function() { redirectPath = redirectSwapSpec.path delete redirectSwapSpec.path } - redirectSwapSpec.pushUrl = redirectSwapSpec.pushUrl || 'true' + redirectSwapSpec.push = redirectSwapSpec.push || 'true' ajaxHelper('get', redirectPath, redirectSwapSpec) return } @@ -5215,7 +5173,8 @@ var htmx = (function() { * @property {Object|FormData} [values] * @property {Record} [headers] * @property {string} [select] - * @property {string} [pushUrl] + * @property {string} [push] + * @property {string} [replace] */ /** @@ -5262,7 +5221,8 @@ var htmx = (function() { * @property {Object|FormData} [values] * @property {boolean} [credentials] * @property {number} [timeout] - * @property {string} [pushUrl] + * @property {string} [push] + * @property {string} [replace] */ /** diff --git a/test/core/api.js b/test/core/api.js index 94acfc041..cb6518d22 100644 --- a/test/core/api.js +++ b/test/core/api.js @@ -657,14 +657,14 @@ describe('Core htmx API test', function() { htmx.process(div.firstChild) }) - it('ajax api pushUrl should push an element into the cache when true', function() { + it('ajax api push Url should push an element into the cache when true', function() { this.server.respondWith('POST', '/test123', 'Clicked!') var div = make("
") htmx.ajax('POST', '/test123', { target: '#d1', swap: 'innerHTML', - pushUrl: 'true' + push: 'true' }) this.server.respond() div.innerHTML.should.equal('Clicked!') @@ -672,14 +672,44 @@ describe('Core htmx API test', function() { path.should.equal('/test123') }) - it('ajax api pushUrl should push an element into the cache when string', function() { + it('ajax api push Url should push an element into the cache when string', function() { this.server.respondWith('POST', '/test', 'Clicked!') var div = make("
") htmx.ajax('POST', '/test', { target: '#d1', swap: 'innerHTML', - pushUrl: '/abc123' + push: '/abc123' + }) + this.server.respond() + div.innerHTML.should.equal('Clicked!') + var path = sessionStorage.getItem('htmx-current-path-for-history') + path.should.equal('/abc123') + }) + + it('ajax api replace Url should replace an element into the cache when true', function() { + this.server.respondWith('POST', '/test123', 'Clicked!') + + var div = make("
") + htmx.ajax('POST', '/test123', { + target: '#d1', + swap: 'innerHTML', + replace: 'true' + }) + this.server.respond() + div.innerHTML.should.equal('Clicked!') + var path = sessionStorage.getItem('htmx-current-path-for-history') + path.should.equal('/test123') + }) + + it('ajax api replace Url should replace an element into the cache when string', function() { + this.server.respondWith('POST', '/test', 'Clicked!') + + var div = make("
") + htmx.ajax('POST', '/test', { + target: '#d1', + swap: 'innerHTML', + replace: '/abc123' }) this.server.respond() div.innerHTML.should.equal('Clicked!') diff --git a/test/core/headers.js b/test/core/headers.js index 6c4f1975f..5048272c1 100644 --- a/test/core/headers.js +++ b/test/core/headers.js @@ -426,9 +426,9 @@ describe('Core htmx AJAX headers', function() { }, 30) }) - it('should not push new Url on HX-Location if pushUrl false', function(done) { + it('should not push new Url on HX-Location if push Url false', function(done) { sessionStorage.setItem('htmx-current-path-for-history', '/old') - this.server.respondWith('GET', '/test', [200, { 'HX-Location': '{"pushUrl":"false", "path":"/test2", "target":"#work-area"}' }, '']) + this.server.respondWith('GET', '/test', [200, { 'HX-Location': '{"push":"false", "path":"/test2", "target":"#work-area"}' }, '']) this.server.respondWith('GET', '/test2', [200, {}, '
Yay! Welcome
']) var div = make('
') div.click() @@ -442,11 +442,11 @@ describe('Core htmx AJAX headers', function() { }, 30) }) - it('should push different Url on HX-Location if pushUrl is string', function(done) { + it('should push different Url on HX-Location if push Url is string', function(done) { sessionStorage.removeItem('htmx-current-path-for-history') var HTMX_HISTORY_CACHE_NAME = 'htmx-history-cache' sessionStorage.removeItem(HTMX_HISTORY_CACHE_NAME) - this.server.respondWith('GET', '/test', [200, { 'HX-Location': '{"pushUrl":"/abc123", "path":"/test2", "target":"#work-area"}' }, '']) + this.server.respondWith('GET', '/test', [200, { 'HX-Location': '{"push":"/abc123", "path":"/test2", "target":"#work-area"}' }, '']) this.server.respondWith('GET', '/test2', [200, {}, '
Yay! Welcome
']) var div = make('
') div.click() diff --git a/www/content/api.md b/www/content/api.md index 326ef491c..9faa71060 100644 --- a/www/content/api.md +++ b/www/content/api.md @@ -65,7 +65,8 @@ or * `values` - values to submit with the request * `headers` - headers to submit with the request * `select` - allows you to select the content you want swapped from a response - * `pushUrl` - can be `'true'` or a path to push a URL into browser location history + * `push` - can be `'true'` or a path to push a URL into browser location history + * `replace` - can be `'true'` or a path to replace the URL in the browser location history ##### Example diff --git a/www/content/headers/hx-location.md b/www/content/headers/hx-location.md index 84e8bb83c..f254ecea1 100644 --- a/www/content/headers/hx-location.md +++ b/www/content/headers/hx-location.md @@ -30,7 +30,8 @@ Path is required and is url to load the response from. The rest of the data mirr * `values` - values to submit with the request * `headers` - headers to submit with the request * `select` - allows you to select the content you want swapped from a response -* `pushUrl` - set to `'false'` or a path string to prevent or override the URL pushed to browser location history +* `push` - set to `'false'` or a path string to prevent or override the URL pushed to browser location history +* `replace` - a path string to prevent or override the URL replaced in the browser location history ## Notes From 1c9e9b6de73e9aad4d16471c82c56ef99aa929e1 Mon Sep 17 00:00:00 2001 From: MichaelWest22 Date: Tue, 5 Aug 2025 16:25:04 +1200 Subject: [PATCH 05/10] minor wording change --- www/content/headers/hx-location.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/www/content/headers/hx-location.md b/www/content/headers/hx-location.md index f254ecea1..a91c4228c 100644 --- a/www/content/headers/hx-location.md +++ b/www/content/headers/hx-location.md @@ -31,7 +31,7 @@ Path is required and is url to load the response from. The rest of the data mirr * `headers` - headers to submit with the request * `select` - allows you to select the content you want swapped from a response * `push` - set to `'false'` or a path string to prevent or override the URL pushed to browser location history -* `replace` - a path string to prevent or override the URL replaced in the browser location history +* `replace` - a path string to replace the URL in the browser location history ## Notes From 3a7f76b40240034a51a461827dae094e073ce424 Mon Sep 17 00:00:00 2001 From: MichaelWest22 Date: Wed, 6 Aug 2025 01:33:52 +1200 Subject: [PATCH 06/10] push headers support true --- www/content/headers/hx-push-url.md | 5 +++-- www/content/headers/hx-replace-url.md | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/www/content/headers/hx-push-url.md b/www/content/headers/hx-push-url.md index 7a3c47b59..89e19c336 100644 --- a/www/content/headers/hx-push-url.md +++ b/www/content/headers/hx-push-url.md @@ -6,7 +6,7 @@ description = """\ The `HX-Push-Url` header allows you to push a URL into the browser [location history](https://developer.mozilla.org/en-US/docs/Web/API/History_API). This creates a new history entry, allowing navigation with the browser’s back and forward buttons. -This is similar to the [`hx-push-url` attribute](@/attributes/hx-push-url.md). +It works the same as [`hx-push-url` attribute](@/attributes/hx-push-url.md). If present, this header overrides any behavior defined with attributes. @@ -14,7 +14,8 @@ The possible values for this header are: 1. A URL to be pushed into the location bar. This may be relative or absolute, as per [`history.pushState()`](https://developer.mozilla.org/en-US/docs/Web/API/History/pushState). -2. `false`, which prevents the browser’s history from being updated. +2. `true`, which pushes the fetched URL into history and location bar. +3. `false`, which prevents the browser’s history from being updated. ## Notes diff --git a/www/content/headers/hx-replace-url.md b/www/content/headers/hx-replace-url.md index 12f060586..ffd39f17a 100644 --- a/www/content/headers/hx-replace-url.md +++ b/www/content/headers/hx-replace-url.md @@ -7,7 +7,7 @@ description = """\ The `HX-Replace-Url` header allows you to replace the current URL in the browser [location history](https://developer.mozilla.org/en-US/docs/Web/API/History_API). This does not create a new history entry; in effect, it removes the previous current URL from the browser’s history. -This is similar to the [`hx-replace-url` attribute](@/attributes/hx-replace-url.md). +It works the same as [`hx-replace-url` attribute](@/attributes/hx-replace-url.md). If present, this header overrides any behavior defined with attributes. @@ -15,7 +15,8 @@ The possible values for this header are: 1. A URL to replace the current URL in the location bar. This may be relative or absolute, as per [`history.replaceState()`](https://developer.mozilla.org/en-US/docs/Web/API/History/replaceState), but must have the same origin as the current URL. -2. `false`, which prevents the browser’s current URL from being updated. +2. `true`, which replaces the fetched URL into history and location bar. +3. `false`, which prevents the browser’s current URL from being updated. ## Notes From 968dca93e82b9e1991a8debc2fc76e60fcd54595 Mon Sep 17 00:00:00 2001 From: MichaelWest22 Date: Fri, 8 Aug 2025 11:32:22 +1200 Subject: [PATCH 07/10] roll back anchor support for header base paths except for true case --- src/htmx.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/htmx.js b/src/htmx.js index e35906153..84517e6c6 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -4638,9 +4638,10 @@ var htmx = (function() { let push = xhr.getResponseHeader('HX-Push') || xhr.getResponseHeader('HX-Push-Url') let replace = xhr.getResponseHeader('HX-Replace-Url') + const headerPath = push || replace // if there was a response header, that has priority - if (!push && !replace) { + if (!headerPath) { // Next resolve via DOM values push = getClosestAttributeValue(elt, 'hx-push-url') || etc.push replace = getClosestAttributeValue(elt, 'hx-replace-url') || etc.replace @@ -4648,8 +4649,7 @@ var htmx = (function() { push = 'true' } } - - let path = push || replace + let path = headerPath || push || replace // unset or false indicates no push, return empty object if (!path || path === 'false') { return {} @@ -4659,8 +4659,8 @@ var htmx = (function() { if (path === 'true') { path = pathInfo.responsePath || pathInfo.finalRequestPath // if there is no response path, go with the original request path } - // restore any anchor associated with the request - if (pathInfo.anchor && path.indexOf('#') === -1) { + // restore any anchor associated with the request except for paths from respone headers to keep old behaviour + if ((!headerPath || headerPath === 'true') && pathInfo.anchor && path.indexOf('#') === -1) { path = path + '#' + pathInfo.anchor } From c3a1d7a52ebeb46d411c871b97ca2caf52750020 Mon Sep 17 00:00:00 2001 From: MichaelWest22 Date: Sat, 9 Aug 2025 00:30:17 +1200 Subject: [PATCH 08/10] add selectOOB and simplify ajax helper --- src/htmx.js | 27 ++++++++++++--------------- test/core/api.js | 26 ++++++++++++++++++++++++++ www/content/api.md | 2 ++ 3 files changed, 40 insertions(+), 15 deletions(-) diff --git a/src/htmx.js b/src/htmx.js index 84517e6c6..1d4c59aa9 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -4039,24 +4039,19 @@ var htmx = (function() { returnPromise: true }) } else { - let resolvedTarget = resolveTarget(context.target) + const { target, swap, source, event, ...restContext } = context + let resolvedTarget = resolveTarget(target) // If target is supplied but can't resolve OR source is supplied but both target and source can't be resolved // then use DUMMY_ELT to abort the request with htmx:targetError to avoid it replacing body by mistake - if ((context.target && !resolvedTarget) || (context.source && !resolvedTarget && !resolveTarget(context.source))) { + if ((target && !resolvedTarget) || (source && !resolvedTarget && !resolveTarget(source))) { resolvedTarget = DUMMY_ELT } - return issueAjaxRequest(verb, path, resolveTarget(context.source), context.event, - { - handler: context.handler, - headers: context.headers, - values: context.values, - targetOverride: resolvedTarget, - swapOverride: context.swap, - select: context.select, - returnPromise: true, - push: context.push, - replace: context.replace - }) + return issueAjaxRequest(verb, path, resolveTarget(source), event, { + ...restContext, + targetOverride: resolvedTarget, + swapOverride: swap, + returnPromise: true + }) } } else { return issueAjaxRequest(verb, path, null, null, { @@ -4856,7 +4851,7 @@ var htmx = (function() { selectOverride = xhr.getResponseHeader('HX-Reselect') } - const selectOOB = getClosestAttributeValue(elt, 'hx-select-oob') + const selectOOB = etc.selectOOB || getClosestAttributeValue(elt, 'hx-select-oob') const select = getClosestAttributeValue(elt, 'hx-select') swap(target, serverResponse, swapSpec, { @@ -5175,6 +5170,7 @@ var htmx = (function() { * @property {string} [select] * @property {string} [push] * @property {string} [replace] + * @property {string} [selectOOB] */ /** @@ -5223,6 +5219,7 @@ var htmx = (function() { * @property {number} [timeout] * @property {string} [push] * @property {string} [replace] + * @property {string} [selectOOB] */ /** diff --git a/test/core/api.js b/test/core/api.js index cb6518d22..334248177 100644 --- a/test/core/api.js +++ b/test/core/api.js @@ -319,6 +319,16 @@ describe('Core htmx API test', function() { div.innerHTML.should.equal('
bar
') }) + it('ajax api works with selectOOB', function() { + this.server.respondWith('GET', '/test', "
OOB Content
Main Content
") + var target = make("
") + var oobDiv = make("
") + htmx.ajax('GET', '/test', { target: '#target', selectOOB: '#oob:innerHTML' }) + this.server.respond() + target.innerHTML.should.equal('
Main Content
') + oobDiv.innerHTML.should.equal('OOB Content') + }) + it('ajax api works with Hx-Select overrides select', function() { this.server.respondWith('GET', '/test', [200, { 'HX-Reselect': '#d2' }, "
foo
bar
"]) var div = make("
") @@ -716,4 +726,20 @@ describe('Core htmx API test', function() { var path = sessionStorage.getItem('htmx-current-path-for-history') path.should.equal('/abc123') }) + + it('ajax api passes custom context properties to htmx events', function() { + this.server.respondWith('GET', '/test', 'response') + var div = make("
") + var customProp = null + var handler = htmx.on('htmx:beforeRequest', function(event) { + customProp = event.detail.etc.customProperty + }) + htmx.ajax('GET', '/test', { + target: '#d1', + customProperty: 'testValue' + }) + this.server.respond() + customProp.should.equal('testValue') + htmx.off('htmx:beforeRequest', handler) + }) }) diff --git a/www/content/api.md b/www/content/api.md index 9faa71060..7d0dfd54a 100644 --- a/www/content/api.md +++ b/www/content/api.md @@ -65,8 +65,10 @@ or * `values` - values to submit with the request * `headers` - headers to submit with the request * `select` - allows you to select the content you want swapped from a response + * `selectOOB` - allows you to select content for out-of-band swaps from a response * `push` - can be `'true'` or a path to push a URL into browser location history * `replace` - can be `'true'` or a path to replace the URL in the browser location history + * *custom properties* - any additional properties will be passed to some htmx events via `event.detail.etc` ##### Example From 994c705f7b0e9e5e09c6d6295ab225e32d9a29fa Mon Sep 17 00:00:00 2001 From: MichaelWest22 Date: Sat, 6 Sep 2025 00:55:55 +1200 Subject: [PATCH 09/10] Remove refactor --- src/htmx.js | 117 ++++++++++++++++++-------- test/core/api.js | 16 ---- www/content/api.md | 1 - www/content/headers/hx-push-url.md | 5 +- www/content/headers/hx-replace-url.md | 5 +- 5 files changed, 87 insertions(+), 57 deletions(-) diff --git a/src/htmx.js b/src/htmx.js index 1d4c59aa9..1815afb5c 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -4039,19 +4039,25 @@ var htmx = (function() { returnPromise: true }) } else { - const { target, swap, source, event, ...restContext } = context - let resolvedTarget = resolveTarget(target) + let resolvedTarget = resolveTarget(context.target) // If target is supplied but can't resolve OR source is supplied but both target and source can't be resolved // then use DUMMY_ELT to abort the request with htmx:targetError to avoid it replacing body by mistake - if ((target && !resolvedTarget) || (source && !resolvedTarget && !resolveTarget(source))) { + if ((context.target && !resolvedTarget) || (context.source && !resolvedTarget && !resolveTarget(context.source))) { resolvedTarget = DUMMY_ELT } - return issueAjaxRequest(verb, path, resolveTarget(source), event, { - ...restContext, - targetOverride: resolvedTarget, - swapOverride: swap, - returnPromise: true - }) + return issueAjaxRequest(verb, path, resolveTarget(context.source), context.event, + { + handler: context.handler, + headers: context.headers, + values: context.values, + targetOverride: resolvedTarget, + swapOverride: context.swap, + select: context.select, + returnPromise: true, + push: context.push, + replace: context.replace, + selectOOB: context.selectOOB + }) } } else { return issueAjaxRequest(verb, path, null, null, { @@ -4629,39 +4635,82 @@ var htmx = (function() { * @return {HtmxHistoryUpdate} */ function determineHistoryUpdates(elt, responseInfo) { - const { xhr, pathInfo, etc } = responseInfo + const xhr = responseInfo.xhr - let push = xhr.getResponseHeader('HX-Push') || xhr.getResponseHeader('HX-Push-Url') - let replace = xhr.getResponseHeader('HX-Replace-Url') - const headerPath = push || replace + //= ========================================== + // First consult response headers + //= ========================================== + let pathFromHeaders = null + let typeFromHeaders = null + if (hasHeader(xhr, /HX-Push:/i)) { + pathFromHeaders = xhr.getResponseHeader('HX-Push') + typeFromHeaders = 'push' + } else if (hasHeader(xhr, /HX-Push-Url:/i)) { + pathFromHeaders = xhr.getResponseHeader('HX-Push-Url') + typeFromHeaders = 'push' + } else if (hasHeader(xhr, /HX-Replace-Url:/i)) { + pathFromHeaders = xhr.getResponseHeader('HX-Replace-Url') + typeFromHeaders = 'replace' + } // if there was a response header, that has priority - if (!headerPath) { - // Next resolve via DOM values - push = getClosestAttributeValue(elt, 'hx-push-url') || etc.push - replace = getClosestAttributeValue(elt, 'hx-replace-url') || etc.replace - if (!push && !replace && getInternalData(elt).boosted) { - push = 'true' + if (pathFromHeaders) { + if (pathFromHeaders === 'false') { + return {} + } else { + return { + type: typeFromHeaders, + path: pathFromHeaders + } } } - let path = headerPath || push || replace - // unset or false indicates no push, return empty object - if (!path || path === 'false') { - return {} - } - // true indicates we want to follow wherever the server ended up sending us - if (path === 'true') { - path = pathInfo.responsePath || pathInfo.finalRequestPath // if there is no response path, go with the original request path - } - // restore any anchor associated with the request except for paths from respone headers to keep old behaviour - if ((!headerPath || headerPath === 'true') && pathInfo.anchor && path.indexOf('#') === -1) { - path = path + '#' + pathInfo.anchor + //= ========================================== + // Next resolve via DOM values + //= ========================================== + const requestPath = responseInfo.pathInfo.finalRequestPath + const responsePath = responseInfo.pathInfo.responsePath + + const pushUrl = getClosestAttributeValue(elt, 'hx-push-url') || responseInfo.etc.push + const replaceUrl = getClosestAttributeValue(elt, 'hx-replace-url') || responseInfo.etc.replace + const elementIsBoosted = getInternalData(elt).boosted + + let saveType = null + let path = null + + if (pushUrl) { + saveType = 'push' + path = pushUrl + } else if (replaceUrl) { + saveType = 'replace' + path = replaceUrl + } else if (elementIsBoosted) { + saveType = 'push' + path = responsePath || requestPath // if there is no response path, go with the original request path } - return { - type: push ? 'push' : 'replace', - path + if (path) { + // false indicates no push, return empty object + if (path === 'false') { + return {} + } + + // true indicates we want to follow wherever the server ended up sending us + if (path === 'true') { + path = responsePath || requestPath // if there is no response path, go with the original request path + } + + // restore any anchor associated with the request + if (responseInfo.pathInfo.anchor && path.indexOf('#') === -1) { + path = path + '#' + responseInfo.pathInfo.anchor + } + + return { + type: saveType, + path + } + } else { + return {} } } diff --git a/test/core/api.js b/test/core/api.js index 334248177..cd48a9038 100644 --- a/test/core/api.js +++ b/test/core/api.js @@ -726,20 +726,4 @@ describe('Core htmx API test', function() { var path = sessionStorage.getItem('htmx-current-path-for-history') path.should.equal('/abc123') }) - - it('ajax api passes custom context properties to htmx events', function() { - this.server.respondWith('GET', '/test', 'response') - var div = make("
") - var customProp = null - var handler = htmx.on('htmx:beforeRequest', function(event) { - customProp = event.detail.etc.customProperty - }) - htmx.ajax('GET', '/test', { - target: '#d1', - customProperty: 'testValue' - }) - this.server.respond() - customProp.should.equal('testValue') - htmx.off('htmx:beforeRequest', handler) - }) }) diff --git a/www/content/api.md b/www/content/api.md index 7d0dfd54a..5d231fc0e 100644 --- a/www/content/api.md +++ b/www/content/api.md @@ -68,7 +68,6 @@ or * `selectOOB` - allows you to select content for out-of-band swaps from a response * `push` - can be `'true'` or a path to push a URL into browser location history * `replace` - can be `'true'` or a path to replace the URL in the browser location history - * *custom properties* - any additional properties will be passed to some htmx events via `event.detail.etc` ##### Example diff --git a/www/content/headers/hx-push-url.md b/www/content/headers/hx-push-url.md index 89e19c336..7a3c47b59 100644 --- a/www/content/headers/hx-push-url.md +++ b/www/content/headers/hx-push-url.md @@ -6,7 +6,7 @@ description = """\ The `HX-Push-Url` header allows you to push a URL into the browser [location history](https://developer.mozilla.org/en-US/docs/Web/API/History_API). This creates a new history entry, allowing navigation with the browser’s back and forward buttons. -It works the same as [`hx-push-url` attribute](@/attributes/hx-push-url.md). +This is similar to the [`hx-push-url` attribute](@/attributes/hx-push-url.md). If present, this header overrides any behavior defined with attributes. @@ -14,8 +14,7 @@ The possible values for this header are: 1. A URL to be pushed into the location bar. This may be relative or absolute, as per [`history.pushState()`](https://developer.mozilla.org/en-US/docs/Web/API/History/pushState). -2. `true`, which pushes the fetched URL into history and location bar. -3. `false`, which prevents the browser’s history from being updated. +2. `false`, which prevents the browser’s history from being updated. ## Notes diff --git a/www/content/headers/hx-replace-url.md b/www/content/headers/hx-replace-url.md index ffd39f17a..12f060586 100644 --- a/www/content/headers/hx-replace-url.md +++ b/www/content/headers/hx-replace-url.md @@ -7,7 +7,7 @@ description = """\ The `HX-Replace-Url` header allows you to replace the current URL in the browser [location history](https://developer.mozilla.org/en-US/docs/Web/API/History_API). This does not create a new history entry; in effect, it removes the previous current URL from the browser’s history. -It works the same as [`hx-replace-url` attribute](@/attributes/hx-replace-url.md). +This is similar to the [`hx-replace-url` attribute](@/attributes/hx-replace-url.md). If present, this header overrides any behavior defined with attributes. @@ -15,8 +15,7 @@ The possible values for this header are: 1. A URL to replace the current URL in the location bar. This may be relative or absolute, as per [`history.replaceState()`](https://developer.mozilla.org/en-US/docs/Web/API/History/replaceState), but must have the same origin as the current URL. -2. `true`, which replaces the fetched URL into history and location bar. -3. `false`, which prevents the browser’s current URL from being updated. +2. `false`, which prevents the browser’s current URL from being updated. ## Notes From 3bc4dac1513b6e81404221a3a01b77b82a909b06 Mon Sep 17 00:00:00 2001 From: MichaelWest22 Date: Sat, 18 Oct 2025 07:57:01 +1300 Subject: [PATCH 10/10] reverse order of push/replace --- src/htmx.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/htmx.js b/src/htmx.js index 1815afb5c..07d838ad9 100644 --- a/src/htmx.js +++ b/src/htmx.js @@ -4671,8 +4671,8 @@ var htmx = (function() { const requestPath = responseInfo.pathInfo.finalRequestPath const responsePath = responseInfo.pathInfo.responsePath - const pushUrl = getClosestAttributeValue(elt, 'hx-push-url') || responseInfo.etc.push - const replaceUrl = getClosestAttributeValue(elt, 'hx-replace-url') || responseInfo.etc.replace + const pushUrl = responseInfo.etc.push || getClosestAttributeValue(elt, 'hx-push-url') + const replaceUrl = responseInfo.etc.replace || getClosestAttributeValue(elt, 'hx-replace-url') const elementIsBoosted = getInternalData(elt).boosted let saveType = null