Skip to content

Commit 12bea37

Browse files
add history cache to htmax but make it opt in via config (#3898)
* add history cache to htmax but make it opt in via config * add upsert and apline to htmax and fix browser indicator boolean attribute * . * fix up after rebase * remove -htmax sed method and just append some set lines * fix web types version
1 parent 30f1799 commit 12bea37

7 files changed

Lines changed: 217 additions & 4 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
},
2626
"scripts": {
2727
"build": "npm run build:iife && npm run build:esm && npm run build:minify && npm run build:ext && npm run build:htmax && npm run build:editors && npm run build:skills && npm run build:scripts && npm run build:types && npm run build:compress",
28-
"build:htmax": "EXTS='hx-sse hx-ws hx-preload hx-browser-indicator hx-download hx-optimistic hx-targets hx-live'; cat dist/htmx.js $(for e in $EXTS; do echo dist/ext/$e.js; done) > dist/htmax.js && terser --compress --mangle --source-map -o dist/htmax.min.js dist/htmax.js",
28+
"build:htmax": "EXTS='hx-sse hx-ws hx-preload hx-browser-indicator hx-download hx-optimistic hx-targets hx-live hx-history-cache hx-upsert hx-alpine-compat'; { cat dist/htmx.js; printf '\\nhtmx.version += \"-htmax\";\\nhtmx.config.historyCache ??= { disable: true };\\n'; for e in $EXTS; do cat dist/ext/$e.js; done; } > dist/htmax.js && terser --compress --mangle --source-map -o dist/htmax.min.js dist/htmax.js",
2929
"build:skills": "mkdir -p dist/skills && cp src/skills/*.md dist/skills/",
3030
"build:iife": "sed -e 's/__proto__/@@PROTOKEY@@/g' -e 's/__/#/g' -e 's/@@PROTOKEY@@/__proto__/g' src/htmx.js > dist/htmx.js",
3131
"build:esm": "sed -e 's/__proto__/@@PROTOKEY@@/g' -e 's/__/#/g' -e 's/@@PROTOKEY@@/__proto__/g' src/htmx.js > dist/htmx.esm.js && printf '\\nif (typeof window !== \"undefined\") window.htmx = htmx;\\nexport default htmx;\\n' >> dist/htmx.esm.js",

src/editors/jetbrains/htmx.web-types.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://json.schemastore.org/web-types",
33
"name": "htmx",
4-
"version": "4.0.0-beta5",
4+
"version": "4.0.0-beta6",
55
"default-icon": "./htmx.svg",
66
"js-types-syntax": "typescript",
77
"description-markup": "markdown",

src/ext/hx-browser-indicator.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
let cleanupNavigation = null;
1010

1111
function shouldShowIndicator(elt) {
12-
if (api.attributeValue(elt, 'hx-browser-indicator') === 'true') return true;
12+
let val = api.attributeValue(elt, 'hx-browser-indicator');
13+
if (val != null && val !== 'false') return true;
1314
if (htmx.config.boostBrowserIndicator && elt._htmx?.boosted) return true;
1415
return false;
1516
}

test/manual/htmax.html

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta name="htmx-config" content='{"logAll":true,"historyCache":{"disable":false}}'>
6+
<script src="/htmax.js"></script>
7+
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
8+
<style>
9+
.htmx-indicator { opacity: 0; transition: opacity 200ms; }
10+
.htmx-request .htmx-indicator { opacity: 1; }
11+
</style>
12+
</head>
13+
<body class="max-w-3xl mx-auto p-6 my-8 space-y-12">
14+
15+
<h1 class="text-2xl font-bold">htmax bundle — manual test</h1>
16+
<p class="text-sm text-neutral-500">
17+
Version: <code id="version"></code>
18+
<script>document.getElementById('version').textContent = htmx.version;</script>
19+
</p>
20+
21+
<!-- ─── SSE ─────────────────────────────────────────────────────────────── -->
22+
<section class="space-y-3">
23+
<h2 class="text-lg font-semibold">1. SSE (hx-sse)</h2>
24+
<p class="text-sm text-neutral-500">Connects to a stream and receives 5 messages then closes.</p>
25+
<div hx-sse:connect="/htmax/sse-stream">
26+
<div hx-sse:swap="message" hx-sse:close="close" id="sse-out" class="p-3 bg-neutral-100 rounded min-h-8">
27+
Waiting for stream…
28+
</div>
29+
</div>
30+
</section>
31+
32+
<!-- ─── WebSockets ───────────────────────────────────────────────────────── -->
33+
<section class="space-y-3">
34+
<h2 class="text-lg font-semibold">2. WebSockets (hx-ws)</h2>
35+
<p class="text-sm text-neutral-500">Checks the extension is registered (full WS test requires <code>ws-server.js</code>).</p>
36+
<button hx-get="/htmax/ws-status" hx-target="#ws-out"
37+
class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
38+
Check WS extension
39+
</button>
40+
<div id="ws-out" class="p-3 bg-neutral-100 rounded min-h-8"></div>
41+
</section>
42+
43+
<!-- ─── Preload ──────────────────────────────────────────────────────────── -->
44+
<section class="space-y-3">
45+
<h2 class="text-lg font-semibold">3. Preload (hx-preload)</h2>
46+
<p class="text-sm text-neutral-500">Hover the button to preload, then click to swap. Check Network tab — request fires on hover.</p>
47+
<button hx-get="/htmax/preload-target" hx-target="#preload-out" hx-preload
48+
class="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700">
49+
Hover then Click
50+
</button>
51+
<div id="preload-out" class="p-3 bg-neutral-100 rounded min-h-8"></div>
52+
</section>
53+
54+
<!-- ─── Browser Indicator ────────────────────────────────────────────────── -->
55+
<section class="space-y-3">
56+
<h2 class="text-lg font-semibold">4. Browser Indicator (hx-browser-indicator)</h2>
57+
<p class="text-sm text-neutral-500">Browser's native loading spinner should appear during the 1.5s request.</p>
58+
<button hx-get="/htmax/indicator" hx-target="#indicator-out" hx-browser-indicator
59+
class="px-4 py-2 bg-yellow-600 text-white rounded hover:bg-yellow-700">
60+
Trigger slow request
61+
</button>
62+
<div id="indicator-out" class="p-3 bg-neutral-100 rounded min-h-8"></div>
63+
</section>
64+
65+
<!-- ─── Download ─────────────────────────────────────────────────────────── -->
66+
<section class="space-y-3">
67+
<h2 class="text-lg font-semibold">5. Download (hx-download)</h2>
68+
<p class="text-sm text-neutral-500">Should trigger a file download without navigating away.</p>
69+
<button hx-get="/htmax/download" hx-download="htmax-test.txt"
70+
class="px-4 py-2 bg-teal-600 text-white rounded hover:bg-teal-700">
71+
Download file
72+
</button>
73+
</section>
74+
75+
<!-- ─── Optimistic ───────────────────────────────────────────────────────── -->
76+
<section class="space-y-3">
77+
<h2 class="text-lg font-semibold">6. Optimistic (hx-optimistic)</h2>
78+
<p class="text-sm text-neutral-500">Template content shows immediately; server response replaces it after 1.5s.</p>
79+
<button hx-post="/htmax/optimistic" hx-target="#optimistic-out"
80+
hx-optimistic="#optimistic-template"
81+
class="px-4 py-2 bg-orange-600 text-white rounded hover:bg-orange-700">
82+
Submit optimistically
83+
</button>
84+
<template id="optimistic-template">
85+
<span class="text-orange-500 italic">⏳ Saving…</span>
86+
</template>
87+
<div id="optimistic-out" class="p-3 bg-neutral-100 rounded min-h-8"></div>
88+
</section>
89+
90+
<!-- ─── Targets ──────────────────────────────────────────────────────────── -->
91+
<section class="space-y-3">
92+
<h2 class="text-lg font-semibold">7. Targets (hx-targets)</h2>
93+
<p class="text-sm text-neutral-500">Same response swapped into two elements simultaneously.</p>
94+
<button hx-get="/htmax/targets" hx-targets="#targets-out-1, #targets-out-2"
95+
class="px-4 py-2 bg-pink-600 text-white rounded hover:bg-pink-700">
96+
Swap two targets
97+
</button>
98+
<div class="flex gap-4">
99+
<div id="targets-out-1" class="flex-1 p-3 bg-neutral-100 rounded min-h-8">Target 1</div>
100+
<div id="targets-out-2" class="flex-1 p-3 bg-neutral-100 rounded min-h-8">Target 2</div>
101+
</div>
102+
</section>
103+
104+
<!-- ─── Live ─────────────────────────────────────────────────────────────── -->
105+
<section class="space-y-3">
106+
<h2 class="text-lg font-semibold">8. Live (hx-live)</h2>
107+
<p class="text-sm text-neutral-500">Counter increments live as you type.</p>
108+
<div class="flex gap-4 items-center">
109+
<input id="live-input" type="text" placeholder="Type here…"
110+
class="border rounded px-3 py-2 flex-1">
111+
<span hx-live:text="document.getElementById('live-input').value.length + ' chars'"
112+
class="text-lg font-mono w-24 text-right">0 chars</span>
113+
</div>
114+
</section>
115+
116+
<!-- ─── Upsert ───────────────────────────────────────────────────────────── -->
117+
<section class="space-y-3">
118+
<h2 class="text-lg font-semibold">9. Upsert (hx-upsert)</h2>
119+
<p class="text-sm text-neutral-500">Item 1 updates in place, Item 3 is inserted. Item 2 is preserved.</p>
120+
<button hx-post="/htmax/upsert" hx-swap="none"
121+
class="px-4 py-2 bg-violet-600 text-white rounded hover:bg-violet-700">
122+
Upsert items
123+
</button>
124+
<div id="upsert-list" class="space-y-2 mt-2">
125+
<div id="item-1" class="p-2 bg-neutral-100 rounded">Item 1 (original)</div>
126+
<div id="item-2" class="p-2 bg-neutral-100 rounded">Item 2 (should stay)</div>
127+
</div>
128+
</section>
129+
130+
<!-- ─── Alpine Compat ────────────────────────────────────────────────────── -->
131+
<section class="space-y-3">
132+
<h2 class="text-lg font-semibold">10. Alpine Compat (hx-alpine-compat)</h2>
133+
<p class="text-sm text-neutral-500">
134+
Alpine is not loaded here — extension should silently no-op.
135+
Check console for errors (there should be none).
136+
</p>
137+
<button hx-get="/htmax/clicked" hx-target="#alpine-out"
138+
class="px-4 py-2 bg-sky-600 text-white rounded hover:bg-sky-700">
139+
Trigger swap (no Alpine errors)
140+
</button>
141+
<div id="alpine-out" class="p-3 bg-neutral-100 rounded min-h-8"></div>
142+
</section>
143+
144+
<!-- ─── History Cache ────────────────────────────────────────────────────── -->
145+
<section class="space-y-3">
146+
<h2 class="text-lg font-semibold">11. History Cache (hx-history-cache)</h2>
147+
<p class="text-sm text-neutral-500">
148+
Navigate to page 2, then hit the browser back button.
149+
The restore should come from <code>sessionStorage</code> (no network request in DevTools).
150+
</p>
151+
<div id="history-content">
152+
<p class="mb-2">Page 1 — initial content.</p>
153+
<a hx-get="/htmax/page2" hx-target="#history-content" hx-push-url="/htmax/page2"
154+
href="/htmax/page2"
155+
class="text-blue-600 underline cursor-pointer">
156+
Go to page 2
157+
</a>
158+
</div>
159+
</section>
160+
161+
<footer class="pt-6 border-t border-neutral-200 text-sm text-neutral-400">
162+
Check browser console — <code>htmx.config.logAll</code> is on.
163+
History cache is enabled via <code>historyCache.disable:false</code> in the meta tag.
164+
</footer>
165+
166+
</body>
167+
</html>

test/manual/server.js

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,36 @@ const routes = {
2727
'/ios-sse': serve('test/manual/ios-sse.html'),
2828
'/htmx.js': serve('src/htmx.js', 'application/javascript'),
2929
'/ext/hx-sse.js': serve('src/ext/hx-sse.js', 'application/javascript'),
30+
'/htmax.js': serve('dist/htmax.js', 'application/javascript'),
31+
'/htmax': serve('test/manual/htmax.html'),
32+
33+
// htmax test endpoints
34+
'/htmax/clicked': (req, res) => { res.writeHead(200, {'Content-Type': 'text/html'}); res.end('<span class="text-green-600 font-semibold">✓ SSE extension loaded (hx-sse registered)</span>'); },
35+
'/htmax/ws-status': (req, res) => { res.writeHead(200, {'Content-Type': 'text/html'}); res.end('<span class="text-green-600 font-semibold">✓ WS extension loaded (hx-ws registered)</span>'); },
36+
'/htmax/preload-target':(req, res) => { res.writeHead(200, {'Content-Type': 'text/html'}); res.end('<span class="text-green-600 font-semibold">✓ Preloaded and swapped!</span>'); },
37+
'/htmax/download': (req, res) => { res.writeHead(200, {'Content-Type': 'text/plain', 'Content-Disposition': 'attachment; filename="htmax-test.txt"'}); res.end('htmax download test'); },
38+
'/htmax/optimistic': (req, res) => { setTimeout(() => { res.writeHead(200, {'Content-Type': 'text/html'}); res.end('<span class="text-green-600 font-semibold">✓ Server confirmed</span>'); }, 1500); },
39+
'/htmax/targets': (req, res) => { res.writeHead(200, {'Content-Type': 'text/html'}); res.end('<span class="text-green-600 font-semibold">✓ hx-targets swapped both elements</span>'); },
40+
'/htmax/upsert': (req, res) => { res.writeHead(200, {'Content-Type': 'text/html'}); res.end('<template hx type="upsert" hx-target="#upsert-list"><div id="item-1" class="p-2 bg-green-100 rounded">Item 1 (updated)</div><div id="item-3" class="p-2 bg-blue-100 rounded">Item 3 (new)</div></template>'); },
41+
'/htmax/page2': (req, res) => { res.writeHead(200, {'Content-Type': 'text/html'}); res.end('<div id="history-content" hx-push-url="/htmax/page2"><p>Page 2 content — hit back to test history cache restore.</p><a hx-get="/htmax/page1" hx-target="#history-content" hx-push-url="/htmax/page1" href="/htmax/page1">Back to page 1</a></div>'); },
42+
'/htmax/page1': (req, res) => { res.writeHead(200, {'Content-Type': 'text/html'}); res.end('<div id="history-content" hx-push-url="/htmax/page1"><p>Page 1 content — restored from cache!</p><a hx-get="/htmax/page2" hx-target="#history-content" hx-push-url="/htmax/page2" href="/htmax/page2">Go to page 2</a></div>'); },
43+
'/htmax/indicator': (req, res) => { setTimeout(() => { res.writeHead(200, {'Content-Type': 'text/html'}); res.end('<span class="text-green-600 font-semibold">✓ Request complete</span>'); }, 1500); },
44+
'/htmax/live-count': (req, res) => { res.writeHead(200, {'Content-Type': 'text/html'}); res.end('<span id="live-count">0</span>'); },
45+
46+
'/htmax/sse-stream': sse((req, res) => {
47+
let n = 0;
48+
const send = () => {
49+
if (n++ < 5) {
50+
res.write(`data: <span>SSE message #${n}</span>\n\n`);
51+
setTimeout(send, 800);
52+
} else {
53+
res.write(`event: close\ndata: <span class="text-green-600 font-semibold">✓ Stream complete</span>\n\n`);
54+
}
55+
};
56+
send();
57+
}),
58+
59+
'/htmax/ws': (req, res) => { res.writeHead(200, {'Content-Type': 'text/html'}); res.end('<span class="text-green-600 font-semibold">✓ WS extension present (full WS test requires ws-server.js)</span>'); },
3060

3161
'/heartbeat': sse((req, res) => {
3262
let count = 0;

www/src/content/docs.mdx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,13 +80,22 @@ The `htmax.js` file bundles htmx with the most popular extensions in a single fi
8080
* [`hx-optimistic`](/extensions/hx-optimistic)
8181
* [`hx-targets`](/extensions/hx-targets)
8282
* [`hx-live`](/extensions/hx-live)
83+
* [`hx-upsert`](/extensions/hx-upsert)
84+
* [`hx-alpine-compat`](/extensions/hx-alpine-compat)
85+
* [`hx-history-cache`](/extensions/hx-history-cache) _(included but disabled by default)_
8386

8487
The extensions are automatically available, you can just use their attributes directly (e.g. `hx-sse:connect`, `hx-ws:connect`).
8588

8689
```html
8790
<script src="/js/htmax.min.js"></script>
8891
```
8992

93+
To enable history caching, opt in via the meta tag:
94+
95+
```html
96+
<meta name="htmx-config" content='{"historyCache": {"disable": false}}'>
97+
```
98+
9099
## Migration
91100

92101
There are two major behavioral changes between htmx 2.x and 4.x:

www/src/content/extensions/13-hx-history-cache.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ The `history-cache` extension replaces htmx's default history handling with a cl
1515
<script src="/path/to/ext/hx-history-cache.js"></script>
1616
```
1717

18+
If you are using the [htmax bundle](/docs#htmax), `hx-history-cache` is already included but disabled by default. Opt in with:
19+
20+
```html
21+
<meta name="htmx-config" content='{"historyCache": {"disable": false}}'>
22+
```
23+
1824
## Usage
1925

2026
No markup changes are required. Once the script is loaded, all htmx-driven navigation is cached automatically.
@@ -45,7 +51,7 @@ To use morphing for smoother restores:
4551
|--------|---------|-------------|
4652
| `size` | `10` | Maximum number of pages to keep in the cache. Oldest entries are evicted first. Set to `0` to disable caching entirely. |
4753
| `refreshOnMiss` | `false` | When `true`, forces a full page reload if the requested history entry is not in the cache. |
48-
| `disable` | `false` | Disables the extension without unloading it. |
54+
| `disable` | `false` (`true` in htmax) | Disables the extension without unloading it. When using the htmax bundle, history caching is off by default — opt in via the meta config tag. |
4955
| `swapStyle` | `"outerSync"` | The htmx swap style used when restoring cached content. Defaults to `outerSync`, which preserves the target element in the DOM (keeping listeners and component state) while syncing its attributes and replacing its children. Use `innerHTML` to replace children only without syncing attributes. Can be set to `"innerMorph"` or `"outerMorph"` for smooth DOM diffing. |
5056

5157
## Events

0 commit comments

Comments
 (0)