├── .DS_Store
├── .github
└── workflows
│ └── test.yml
├── .gitignore
├── LICENSE
├── README.md
├── hx-optimistic.d.ts
├── hx-optimistic.js
├── hx-optimistic.min.js
├── package-lock.json
├── package.json
├── playwright-report
└── index.html
├── src
├── config.js
├── constants.js
├── extension.js
├── index.js
└── utils.js
├── test-results
└── .last-run.json
├── tests
├── README.md
├── helpers
│ ├── setup.js
│ └── test-utils.js
├── integration
│ ├── basic-updates.test.js
│ ├── error-handling.test.js
│ └── lifecycle-context.test.js
└── unit
│ ├── interpolation.test.js
│ └── target-resolution.test.js
└── vitest.config.js
/.DS_Store:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/lorenseanstewart/hx-optimistic/fe2203fa8bc7b6ae574061a7a11f6ef56dbd7490/.DS_Store
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Test
2 |
3 | on:
4 | push:
5 | branches: [ main, development ]
6 | tags: [ 'v*' ]
7 | pull_request:
8 | branches: [ main ]
9 |
10 | jobs:
11 | test:
12 | runs-on: ubuntu-latest
13 |
14 | strategy:
15 | matrix:
16 | node-version: [20.x]
17 |
18 | steps:
19 | - uses: actions/checkout@v3
20 |
21 | - name: Use Node.js ${{ matrix.node-version }}
22 | uses: actions/setup-node@v3
23 | with:
24 | node-version: ${{ matrix.node-version }}
25 | cache: 'npm'
26 |
27 | - name: Install dependencies
28 | run: npm ci
29 |
30 | - name: Run unit and integration tests
31 | run: npm run test:run
32 |
33 | - name: Generate coverage report
34 | if: matrix.node-version == '20.x'
35 | run: npm run test:coverage
36 |
37 | - name: Upload coverage to Codecov
38 | if: matrix.node-version == '20.x'
39 | uses: codecov/codecov-action@v3
40 | with:
41 | file: ./coverage/lcov.info
42 | flags: unittests
43 | name: codecov-umbrella
44 | fail_ci_if_error: false
45 |
46 |
47 |
48 | lint:
49 | runs-on: ubuntu-latest
50 |
51 | steps:
52 | - uses: actions/checkout@v3
53 |
54 | - name: Use Node.js
55 | uses: actions/setup-node@v3
56 | with:
57 | node-version: '20.x'
58 | cache: 'npm'
59 |
60 | - name: Install dependencies
61 | run: npm ci
62 |
63 | - name: Check code formatting
64 | run: |
65 | # Add linting commands if you have them
66 | echo "Linting checks would go here"
67 |
68 | - name: Type check
69 | run: |
70 | # TypeScript checking if applicable
71 | echo "Type checking would go here"
72 |
73 | build:
74 | runs-on: ubuntu-latest
75 | needs: [test, lint]
76 |
77 | steps:
78 | - uses: actions/checkout@v3
79 |
80 | - name: Use Node.js
81 | uses: actions/setup-node@v3
82 | with:
83 | node-version: '20.x'
84 | cache: 'npm'
85 |
86 | - name: Install dependencies
87 | run: npm ci
88 |
89 | - name: Build minified version
90 | run: npm run build
91 |
92 | - name: Check build output
93 | run: |
94 | test -f hx-optimistic.min.js
95 |
96 | - name: Upload build artifacts
97 | uses: actions/upload-artifact@v4
98 | with:
99 | name: build-output
100 | path: |
101 | hx-optimistic.js
102 | hx-optimistic.min.js
103 |
104 | publish:
105 | runs-on: ubuntu-latest
106 | needs: [build]
107 | if: startsWith(github.ref, 'refs/tags/v')
108 | steps:
109 | - uses: actions/checkout@v3
110 | - name: Use Node.js
111 | uses: actions/setup-node@v3
112 | with:
113 | node-version: '20.x'
114 | cache: 'npm'
115 | registry-url: 'https://registry.npmjs.org'
116 | - name: Install dependencies
117 | run: npm ci
118 | - name: Build
119 | run: npm run build
120 | - name: Publish to npm
121 | env:
122 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
123 | run: npm publish --access public
124 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules
2 | /dist
3 | .astro/
4 | coverage
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 HTMX Community
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # hx-optimistic
2 |
3 | An htmx extension for optimistic UI updates with automatic rollback on errors. Combine it with speculation rules (or the htmx preload extension) and the View Transitions API for truly app‑like experience with minimal JavaScript. We love JavaScript, but use it like a spice: a pinch delights, too much overwhelms.
4 |
5 | ## ✨ Features
6 |
7 | - 🎯 **Optimistic Updates** - Immediate UI feedback while requests are processing
8 | - 🔄 **Automatic Rollback** - Intelligent revert to original state on errors
9 | - 📝 **Input Interpolation** - Dynamic templates with `${this.value}`, `${textarea}`, `${data:key}` helpers
10 | - 🎨 **Template Support** - Rich HTML templates for loading and error states
11 | - ⚠️ **Developer Warnings** - Console warnings for unsupported patterns
12 | - 🚫 **No CSS Required** - You control all styling through provided class names
13 | - 📦 **Tiny** - Only **18.2KB** uncompressed, **8.4KB** minified, **3.0KB** gzipped
14 | - 🔧 **Highly Configurable** - Fine-tune behavior per element
15 |
16 | ## 🚀 Quick Start
17 |
18 | ### Installation
19 |
20 | **Via CDN (jsDelivr, pinned major):**
21 | ```html
22 |
23 |
24 | ```
25 |
26 | Alternative (unpkg, latest v1):
27 | ```html
28 |
29 |
30 | ```
31 |
32 | Compatibility: Works with `htmx` 1.9+ and 2.x.
33 |
34 | **Via NPM:**
35 | ```bash
36 | npm install hx-optimistic
37 | ```
38 |
39 | ### Basic Usage
40 |
41 | Enable the extension and add optimistic behavior to any HTMX element:
42 |
43 | ```html
44 |
45 |
46 |
52 | 🤍 Like
53 |
54 |
55 | ```
56 |
57 | ## 🎯 Core Concepts
58 |
59 | - Use `data-optimistic` to declare behavior per element
60 | - Choose between `values` (simple property changes) and `template` (full HTML)
61 | - Automatic snapshot and revert: `innerHTML`, `className`, attributes, and `dataset`
62 | - Token-based concurrency prevents stale errors from overwriting newer states
63 | - Target resolution via `hx-target` or config `target` chains: `closest`, `find`, `next`, `previous`
64 | - Errors via `errorMessage` or `errorTemplate` with `errorMode` and `delay`
65 | - Interpolation supports safe patterns only; avoid arbitrary JS expressions
66 |
67 | ## 📦 Bundle Size
68 |
69 | | Artifact | Size |
70 | |---------|------|
71 | | Unminified (`hx-optimistic.js`) | 18.2 KB |
72 | | Minified (`hx-optimistic.min.js`) | 8.4 KB |
73 | | Minified + gzip | 3.0 KB |
74 |
75 | ### Values vs Templates
76 |
77 | **Values** - Perfect for simple optimistic updates:
78 | ```html
79 |
92 | 🤍 Like (42)
93 |
94 | ```
95 |
96 | **Templates** - Ideal for complex optimistic UI changes:
97 | ```html
98 |
106 | ```
107 |
108 | ### Input Interpolation
109 |
110 | Dynamic content using `${...}` syntax with powerful helpers:
111 |
112 | ```html
113 |
115 | Your comment here
116 | Post
117 |
118 | ```
119 |
120 | ## 📖 Interpolation Reference
121 |
122 | All `${...}` patterns supported in templates and values:
123 |
124 | | Pattern | Description | Example |
125 | |---------|-------------|---------|
126 | | `${this.value}` | Element's input value | `"Saving: ${this.value}"` |
127 | | `${this.textContent}` | Element's text content | `"Was: ${this.textContent}"` |
128 | | `${this.dataset.key}` | Data attribute via dataset | `"ID: ${this.dataset.userId}"` |
129 | | `${textarea}` | First textarea in form | `"Comment: ${textarea}"` |
130 | | `${email}` | First email input | `"Email: ${email}"` |
131 | | `${data:key}` | Data attribute shorthand | `"Count: ${data:count}"` |
132 | | `${attr:name}` | Any HTML attribute | `"ID: ${attr:id}"` |
133 | | `${contextKey}` | Value from `config.context` (templates only) | `"Hello, ${username}"` |
134 | | `${status}` | HTTP status (errors only) | `"Error ${status}"` |
135 | | `${statusText}` | HTTP status text (errors only) | `"Error: ${statusText}"` |
136 | | `${error}` | Error message (errors only) | `"Failed: ${error}"` |
137 |
138 | **Form Field Helpers:**
139 | - `${textarea}`, `${email}`, `${password}`, `${text}`, `${url}`, `${tel}`, `${search}`
140 | - `${fieldName}` - Any field with `name="fieldName"`
141 |
142 | ## ⚙️ Configuration Options
143 |
144 | Complete configuration reference for `data-optimistic`:
145 |
146 | ### Snapshot Behavior
147 | `innerHTML`, `className`, all attributes, and the element `dataset` are automatically captured and restored on revert; no configuration is required.
148 |
149 | ### Optimistic Updates
150 | ```javascript
151 | {
152 | // Simple property updates
153 | "values": {
154 | "textContent": "Loading...",
155 | "className": "btn loading"
156 | },
157 |
158 | // Rich HTML templates
159 | "template": "#loading-template", // Or inline HTML
160 | "target": "closest .card", // Different target for optimistic update
161 | "swap": "beforeend", // Append instead of replace
162 | "class": "my-optimistic" // Optional custom class applied during optimistic state
163 | }
164 | ```
165 |
166 | `swap` supports `beforeend` and `afterbegin`. If omitted, content is replaced.
167 |
168 | ### Error Handling
169 | ```javascript
170 | {
171 | "errorMessage": "Request failed",
172 | "errorTemplate": "Error ${status}: ${statusText}
",
173 | "errorMode": "append", // "replace" (default) or "append"
174 | "delay": 2000 // Auto-revert delay in ms
175 | }
176 | ```
177 |
178 | ### Context Data
179 | Provide additional variables for template interpolation:
180 |
181 | ```json
182 | {
183 | "template": "Hello, ${username}
",
184 | "context": { "username": "Alice" }
185 | }
186 | ```
187 |
188 | Full example:
189 |
190 | ```html
191 |
192 |
Alex
193 |
Status: 🔴 Offline
194 |
201 | Change Status
202 |
203 |
204 |
205 |
206 |
207 |
${username}
208 |
Status: ${nextIcon} ${nextStatus}
209 |
210 |
211 |
212 |
213 | ❌ ${error}
214 |
215 | ```
216 |
217 | ## 🎨 CSS Classes
218 |
219 | This library does not include any CSS. These classes are applied so you can style them as you wish:
220 |
221 | - `hx-optimistic`: applied during the optimistic update
222 | - `hx-optimistic-error`: applied when an error is shown
223 | - `hx-optimistic-reverting`: applied while reverting to the snapshot
224 | - `hx-optimistic-error-message`: wrapper added when errorMode is "append"
225 | - `hx-optimistic-pending`: may be applied to `` when no `values`/`template` are provided
226 |
227 | ## ✅ Best Practices
228 |
229 | - **Enable globally when possible**: Add `hx-ext="optimistic"` on `` so elements inherit it. Use per-element `hx-ext` only when you need to opt-in selectively.
230 | - **Pick the right technique**:
231 | - **values**: simple property changes (`textContent`, `className`, `data-*`).
232 | - **template**: richer markup; prefer a `` and reference it with `"#id"`.
233 | - **Keep interpolation simple**: Only supported patterns are documented (e.g., `${this.value}`, `${textarea}`, `${data:key}`, `${attr:name}`). Avoid expressions like `${count + 1}`; use `data-*`/`hx-vals` to pass values.
234 | - **Design error UX**: Provide `errorMessage` or `errorTemplate`. Use `errorMode: "append"` to preserve content; set `delay` (ms) for auto-revert, or `delay: 0` to keep the error state.
235 | - **Target resolution**: Use `hx-target` or config `target` with chains like `closest .card find .title`. Supported ops: `closest`, `find`, `next`, `previous`. Prefer stable selectors over brittle DOM traversal.
236 | - **Style the states**: Add styles for `hx-optimistic`, `hx-optimistic-error`, `hx-optimistic-reverting`, and `hx-optimistic-error-message`, or provide a custom `class` in config.
237 | - **Concurrency is automatic**: Overlapping requests are tokenized; older errors won’t clobber newer optimistic states. Avoid writing concurrency flags into `dataset`.
238 | - **Snapshots are automatic**: `innerHTML`, `className`, attributes, and dataset are restored automatically on revert; no custom snapshot configuration is needed.
239 | - **Pass extra data via context**: Use `context` to provide additional variables to templates. Error templates also receive `${status}`, `${statusText}`, and `${error}`.
240 | - **Accessibility**: The extension preserves focus within the target after error/revert. Ensure visible focus styles and consider ARIA live regions for error messages.
241 | - **Diagnostics**: Watch the console for warnings about unresolved selectors/templates or unsupported interpolation patterns, and fix the sources accordingly.
242 | - **Default button behavior**: If `data-optimistic` is present on a `` without `values` or `template`, a `hx-optimistic-pending` class is added during the request.
243 |
244 | ## 📚 Examples
245 | See usage snippets above for common patterns.
246 |
247 | ## 🔧 Developer Features
248 |
249 | ### Console Warnings
250 | The extension provides helpful warnings for unsupported patterns:
251 |
252 | ```javascript
253 | // ❌ These will show console warnings
254 | "${this.querySelector('.test')}" // DOM queries not allowed
255 | "${window.location.href}" // Global object access
256 | "${JSON.parse(data)}" // Method calls not supported
257 |
258 | // ✅ These are supported
259 | "${data:user-id}" // Data attributes
260 | "${attr:title}" // HTML attributes
261 | "${this.value}" // Element properties
262 | ```
263 |
264 | ### Lifecycle Events
265 |
266 | Three custom events are dispatched on the optimistic target. Use event delegation to observe them:
267 |
268 | ```html
269 |
288 | ```
289 |
290 | If you prefer htmx utilities:
291 |
292 | ```html
293 |
298 | ```
299 |
300 | ### Template References
301 | Use `` elements for better organization:
302 |
303 | ```html
304 |
305 |
306 |
309 |
310 |
313 |
314 |
316 |
317 | Post Comment
318 |
319 | ```
320 |
321 | ## 🎮 Live Demo
322 | [View the demo](https://hx-optimistic-demo-site.vercel.app/)
323 |
324 | ## 🤝 Contributing
325 |
326 | 1. Fork the repository
327 | 2. Create a feature branch: `git checkout -b feature-name`
328 | 3. Run tests: `npm test`
329 | 4. Make your changes
330 | 5. Submit a pull request
331 |
332 | ## 📦 Release
333 |
334 | Tag-based releases trigger npm publish in CI:
335 |
336 | 1. Update version in `package.json` if needed
337 | 2. Create a tag and push it:
338 | ```bash
339 | git tag v1.0.0
340 | git push origin v1.0.0
341 | ```
342 | 3. Ensure `NPM_TOKEN` is set in GitHub Actions secrets
343 |
344 | ## 📄 License
345 |
346 | MIT License - see [LICENSE](LICENSE) for details.
347 |
348 | ---
349 |
350 | **hx-optimistic** - Making HTMX interactions feel instant with intelligent optimistic updates. ⚡
--------------------------------------------------------------------------------
/hx-optimistic.d.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * TypeScript definitions for hx-optimistic
3 | * HTMX extension for optimistic UI updates with automatic rollback on errors
4 | *
5 | * @version 1.0.0
6 | */
7 |
8 | declare global {
9 | namespace htmx {
10 | interface HtmxExtensions {
11 | optimistic: OptimisticExtension;
12 | }
13 | }
14 | }
15 |
16 | /**
17 | * Configuration object for the hx-optimistic extension
18 | * Passed as JSON in the data-optimistic attribute
19 | */
20 | export interface OptimisticConfig {
21 | /**
22 | * Simple property updates to apply immediately when request starts
23 | * @example { "textContent": "Loading...", "className": "btn loading" }
24 | */
25 | values?: Record;
26 |
27 | /**
28 | * HTML template to show during request
29 | * If starts with "#": treated as template element ID
30 | * Otherwise: treated as inline HTML string
31 | * Supports ${...} variable substitution
32 | * @example "#loading-template" or "Loading...
"
33 | */
34 | template?: string;
35 |
36 | /**
37 | * Optional selector chain (e.g. "closest .card", "find .target") to resolve the optimistic target
38 | * If omitted, uses the source element or hx-target when provided on the element
39 | */
40 | target?: string;
41 |
42 | /**
43 | * How to swap template content
44 | * - replace: sets innerHTML
45 | * - beforeend: insertAdjacentHTML('beforeend')
46 | * - afterbegin: insertAdjacentHTML('afterbegin')
47 | */
48 | swap?: 'replace' | 'beforeend' | 'afterbegin';
49 |
50 | /**
51 | * Custom CSS class to apply during optimistic update
52 | * @default "hx-optimistic"
53 | */
54 | class?: string;
55 |
56 | /**
57 | * Simple text message to show on error
58 | * Replaces element content unless errorMode is "append"
59 | */
60 | errorMessage?: string;
61 |
62 | /**
63 | * HTML template for rich error display
64 | * If starts with "#": template element ID
65 | * Otherwise: inline HTML string
66 | * Supports error variables: ${status}, ${statusText}, ${error}
67 | * @example "Error ${status}: ${statusText}
"
68 | */
69 | errorTemplate?: string;
70 |
71 | /**
72 | * How to display error content
73 | * @default "replace"
74 | */
75 | errorMode?: 'replace' | 'append';
76 |
77 | /**
78 | * Milliseconds before reverting to original state
79 | * Set to 0 to disable automatic revert
80 | * @default 2000
81 | */
82 | delay?: number;
83 |
84 | /** Optional keys to snapshot for granular restore (defaults to innerHTML & className) */
85 | snapshot?: string[];
86 |
87 | /** Additional context merged into template interpolation */
88 | context?: Record;
89 | }
90 |
91 | /**
92 | * Supported interpolation patterns in templates and values
93 | * All patterns use ${...} syntax
94 | */
95 | export type InterpolationPattern =
96 | | '${this.value}' // Element's value property
97 | | '${this.textContent}' // Element's text content
98 | | '${this.dataset.key}' // Data attribute (full syntax)
99 | | '${data:key}' // Data attribute (shorthand)
100 | | '${attr:name}' // Any attribute
101 | | '${Math.max(0, count)}' // Math with count variable
102 | | '${Math.min(a, b)}' // Math functions
103 | | '${status}' // Error status (error templates only)
104 | | '${statusText}' // Error text (error templates only)
105 | | '${error}'; // Error message (error templates only)
106 |
107 | /**
108 | * Internal snapshot data structure
109 | * @internal
110 | */
111 | interface SnapshotData {
112 | innerHTML: string;
113 | className: string;
114 | textContent?: string;
115 | fullContent?: string;
116 | sourceElement: Element;
117 | config: OptimisticConfig;
118 | token: number;
119 | }
120 |
121 | /**
122 | * The hx-optimistic extension implementation
123 | * @internal
124 | */
125 | interface OptimisticExtension {
126 | onEvent(name: string, evt: Event): void;
127 | handleBeforeRequest(evt: Event): void;
128 | handleError(evt: Event): void;
129 | showError(targetElt: Element, config: OptimisticConfig, evt: Event): void;
130 | snapshot(targetElt: Element, sourceElt: Element, config: OptimisticConfig, token: number): void;
131 | applyOptimistic(targetElt: Element, sourceElt: Element, config: OptimisticConfig): void;
132 | revert(targetElt: Element, expectedToken?: number): void;
133 | cleanup(target: Element): void;
134 | getTemplate(templateId: string): string | null;
135 | applyValues(targetElt: Element, values: Record, sourceElt: Element): void;
136 | }
137 |
138 | /**
139 | * HTML element with optimistic configuration
140 | * Extends HTMLElement to include the data-optimistic attribute
141 | */
142 | declare global {
143 | interface HTMLElement {
144 | /**
145 | * JSON configuration for optimistic updates
146 | * @example '{"template": "#loading", "errorMessage": "Failed"}'
147 | */
148 | 'data-optimistic'?: string;
149 | }
150 | }
151 |
152 | export {};
--------------------------------------------------------------------------------
/hx-optimistic.js:
--------------------------------------------------------------------------------
1 | (() => {
2 | // src/constants.js
3 | var CLASS_OPTIMISTIC = "hx-optimistic";
4 | var CLASS_ERROR = "hx-optimistic-error";
5 | var CLASS_REVERTING = "hx-optimistic-reverting";
6 | var DATASET_OPTIMISTIC_KEY = "optimistic";
7 |
8 | // src/utils.js
9 | function findClosestInAncestorSubtrees(startElt, selector) {
10 | let node = startElt;
11 | while (node) {
12 | const match = node.querySelector(selector);
13 | if (match) return match;
14 | node = node.parentElement;
15 | }
16 | return null;
17 | }
18 | function interpolateTemplate(str, sourceElt, data = {}) {
19 | if (typeof str !== "string") return str;
20 | return str.replace(/\${([^}]+)}/g, (match, expr) => {
21 | expr = expr.trim();
22 | if (data[expr] !== void 0) {
23 | return data[expr];
24 | }
25 | if (!sourceElt) return match;
26 | if (expr === "this.value") {
27 | if (sourceElt.value !== void 0) return sourceElt.value;
28 | if (sourceElt.tagName === "FORM") {
29 | const input = sourceElt.querySelector("input, textarea, select");
30 | if (input?.value) return input.value;
31 | }
32 | }
33 | if (sourceElt.tagName === "FORM") {
34 | const selectors = {
35 | textarea: "textarea",
36 | email: 'input[type="email"]',
37 | password: 'input[type="password"]',
38 | text: 'input[type="text"], input:not([type])',
39 | url: 'input[type="url"]',
40 | tel: 'input[type="tel"]',
41 | search: 'input[type="search"]'
42 | };
43 | const selector = selectors[expr] || `[name="${expr}"]`;
44 | const field = sourceElt.querySelector(selector);
45 | if (field?.value) return field.value;
46 | }
47 | if (expr === "this.textContent") return sourceElt.textContent || match;
48 | if (expr.startsWith("this.dataset.")) {
49 | const key = expr.slice(13);
50 | return sourceElt.dataset[key] || match;
51 | }
52 | if (expr.startsWith("data:")) {
53 | const key = expr.slice(5);
54 | const camelKey = key.replace(/-([a-z])/g, (m, l) => l.toUpperCase());
55 | return sourceElt.dataset[camelKey] || match;
56 | }
57 | if (expr.startsWith("attr:")) {
58 | const name = expr.slice(5);
59 | return sourceElt.getAttribute(name) || match;
60 | }
61 | if (expr.includes(".") || expr.includes(":")) {
62 | console.warn(
63 | `[hx-optimistic] Unresolved interpolation pattern: \${${expr}}`,
64 | "\nSupported patterns:",
65 | "\n ${this.value} - element value",
66 | "\n ${this.textContent} - element text content",
67 | "\n ${this.dataset.key} - data attribute",
68 | "\n ${data:key} - data attribute shorthand",
69 | "\n ${attr:name} - any attribute",
70 | "\n ${textarea}, ${email}, ${password}, etc. - form field by type",
71 | "\n ${fieldName} - form field by name",
72 | "\n ${status}, ${statusText}, ${error} - error context",
73 | "\nSee documentation for details."
74 | );
75 | }
76 | return match;
77 | });
78 | }
79 | function resolveTargetChain(sourceElt, targetSelector) {
80 | if (!targetSelector || targetSelector === "this") {
81 | return sourceElt;
82 | }
83 | const selector = String(targetSelector).trim();
84 | const ops = selector.split(/\s+/);
85 | if (["closest", "find", "next", "previous"].some((op) => selector.startsWith(op))) {
86 | let context = sourceElt;
87 | for (let i = 0; i < ops.length; ) {
88 | const op = ops[i++];
89 | const sel = ops[i++] || "";
90 | if (op === "closest") {
91 | let candidate = context ? context.closest(sel) : null;
92 | if (!candidate) {
93 | candidate = findClosestInAncestorSubtrees(context, sel);
94 | }
95 | context = candidate;
96 | if (!context) return null;
97 | } else if (op === "find") {
98 | if (!context) return null;
99 | let found = context.querySelector(sel);
100 | if (!found) {
101 | let ancestor = context.parentElement;
102 | while (ancestor && !found) {
103 | found = ancestor.querySelector(sel);
104 | if (found) break;
105 | ancestor = ancestor.parentElement;
106 | }
107 | }
108 | context = found || null;
109 | if (!context) return null;
110 | } else if (op === "next") {
111 | if (!context) return null;
112 | let next = context.nextElementSibling;
113 | let match = null;
114 | while (next) {
115 | if (next.matches(sel)) {
116 | match = next;
117 | break;
118 | }
119 | next = next.nextElementSibling;
120 | }
121 | context = match;
122 | if (!context) return null;
123 | } else if (op === "previous") {
124 | if (!context) return null;
125 | let prev = context.previousElementSibling;
126 | let match = null;
127 | while (prev) {
128 | if (prev.matches(sel)) {
129 | match = prev;
130 | break;
131 | }
132 | prev = prev.previousElementSibling;
133 | }
134 | context = match;
135 | if (!context) return null;
136 | } else {
137 | return null;
138 | }
139 | }
140 | return context;
141 | }
142 | return document.querySelector(selector);
143 | }
144 | function setOptimisticStateClass(target, state) {
145 | if (!target?.classList) return;
146 | target.classList.remove(CLASS_OPTIMISTIC, CLASS_ERROR, CLASS_REVERTING);
147 | if (state === "optimistic") target.classList.add(CLASS_OPTIMISTIC);
148 | else if (state === "error") target.classList.add(CLASS_ERROR);
149 | else if (state === "reverting") target.classList.add(CLASS_REVERTING);
150 | }
151 | function hasOptimisticConfig(element) {
152 | return Boolean(element?.dataset?.[DATASET_OPTIMISTIC_KEY]);
153 | }
154 | function getTargetFor(sourceElt) {
155 | const targetSelector = sourceElt.getAttribute("hx-target");
156 | if (!targetSelector) return sourceElt;
157 | return resolveTargetChain(sourceElt, targetSelector);
158 | }
159 | function getNextToken(targetElt, tokenMap) {
160 | const currentToken = tokenMap.get(targetElt) || 0;
161 | const newToken = currentToken + 1;
162 | tokenMap.set(targetElt, newToken);
163 | return newToken;
164 | }
165 | function processWithHtmxIfAvailable(element) {
166 | if (typeof htmx !== "undefined" && typeof htmx.process === "function") {
167 | htmx.process(element);
168 | }
169 | }
170 | function removeOptimisticDatasetAttributes(target) {
171 | if (!target?.dataset) return;
172 | Object.keys(target.dataset).filter((key) => key.startsWith("hxOptimistic")).forEach((key) => delete target.dataset[key]);
173 | }
174 | function addCustomOptimisticClass(target, config) {
175 | if (config && config.class && target?.classList) {
176 | target.classList.add(config.class);
177 | }
178 | }
179 | function removeCustomOptimisticClass(target, config) {
180 | if (config && config.class && target?.classList) {
181 | target.classList.remove(config.class);
182 | }
183 | }
184 |
185 | // src/config.js
186 | function getOptimisticConfig(sourceElt, cacheMap) {
187 | if (!sourceElt?.dataset?.[DATASET_OPTIMISTIC_KEY]) return null;
188 | const raw = sourceElt.dataset[DATASET_OPTIMISTIC_KEY];
189 | const cached = cacheMap.get(sourceElt);
190 | if (cached && cached.__raw === raw) {
191 | return cached.config;
192 | }
193 | let config;
194 | try {
195 | if (raw === "true" || raw === "") {
196 | config = {};
197 | } else {
198 | config = JSON.parse(raw);
199 | if (typeof config !== "object" || config === null) {
200 | config = { values: { textContent: raw } };
201 | }
202 | }
203 | } catch (e) {
204 | config = { values: { textContent: raw } };
205 | }
206 | config.delay = config.delay ?? 2e3;
207 | config.errorMode = config.errorMode || "replace";
208 | config.errorMessage = config.errorMessage || "Request failed";
209 | if (!config.values && !config.template && sourceElt.tagName === "BUTTON") {
210 | config.values = {
211 | className: (sourceElt.className + " hx-optimistic-pending").trim()
212 | };
213 | }
214 | cacheMap.set(sourceElt, { __raw: raw, config });
215 | return config;
216 | }
217 |
218 | // src/extension.js
219 | function createExtension(htmx2) {
220 | const configCache = /* @__PURE__ */ new WeakMap();
221 | const snapshots = /* @__PURE__ */ new WeakMap();
222 | const tokens = /* @__PURE__ */ new WeakMap();
223 | const sourceTargets = /* @__PURE__ */ new WeakMap();
224 | return {
225 | onEvent: function(name, evt) {
226 | if (name === "htmx:beforeRequest") {
227 | this.handleBeforeRequest(evt);
228 | } else if (["htmx:responseError", "htmx:swapError", "htmx:timeout", "htmx:sendError"].includes(name)) {
229 | this.handleError(evt);
230 | } else if (name === "htmx:afterSwap") {
231 | this.cleanup(evt.target);
232 | }
233 | },
234 | handleBeforeRequest: function(evt) {
235 | const sourceElt = evt.target;
236 | if (!hasOptimisticConfig(sourceElt)) return;
237 | const config = getOptimisticConfig(sourceElt, configCache);
238 | if (!config) return;
239 | const targetElt = getTargetFor(sourceElt);
240 | if (!targetElt) {
241 | console.warn("[hx-optimistic] Target element not found for:", sourceElt);
242 | return;
243 | }
244 | sourceTargets.set(sourceElt, targetElt);
245 | const newToken = getNextToken(targetElt, tokens);
246 | this.snapshot(targetElt, sourceElt, config, newToken);
247 | this.applyOptimistic(targetElt, sourceElt, config);
248 | setOptimisticStateClass(targetElt, "optimistic");
249 | addCustomOptimisticClass(targetElt, config);
250 | try {
251 | htmx2.trigger && htmx2.trigger(targetElt, "optimistic:applied", { config });
252 | } catch (_) {
253 | }
254 | },
255 | handleError: function(evt) {
256 | const sourceElt = evt.detail?.elt || evt.target;
257 | const targetSelector = sourceElt?.getAttribute("hx-target");
258 | const targetElt = (targetSelector ? resolveTargetChain(sourceElt, targetSelector) : null) || sourceTargets.get(sourceElt) || sourceElt;
259 | if (!targetElt) return;
260 | const snapshot = snapshots.get(targetElt);
261 | const config = snapshot?.config || getOptimisticConfig(sourceElt, configCache);
262 | if (!config) return;
263 | const currentToken = tokens.get(targetElt);
264 | if (snapshot && snapshot.token !== currentToken) return;
265 | const active = document.activeElement;
266 | if (active && targetElt.contains(active)) {
267 | const existing = snapshots.get(targetElt) || { config, token: currentToken };
268 | existing.focusRestore = active;
269 | snapshots.set(targetElt, existing);
270 | }
271 | setOptimisticStateClass(targetElt, "error");
272 | this.showError(targetElt, config, evt);
273 | try {
274 | const errorData = {
275 | status: evt.detail?.xhr?.status || 0,
276 | statusText: evt.detail?.xhr?.statusText || "Network Error",
277 | error: evt.detail?.error || "Request failed"
278 | };
279 | htmx2.trigger && htmx2.trigger(targetElt, "optimistic:error", { config, detail: errorData });
280 | } catch (_) {
281 | }
282 | if (config.delay > 0) {
283 | setTimeout(() => this.revert(targetElt, currentToken), config.delay);
284 | }
285 | },
286 | snapshot: function(targetElt, sourceElt, config, token) {
287 | const attributes = Array.from(targetElt.attributes).reduce((acc, { name, value }) => {
288 | acc[name] = value;
289 | return acc;
290 | }, {});
291 | const dataset = { ...targetElt.dataset };
292 | const pickKeys = Array.isArray(config.snapshot) && config.snapshot.length > 0 ? config.snapshot : ["innerHTML", "className"];
293 | const granular = {};
294 | pickKeys.forEach((k) => {
295 | if (k === "textContent") granular.textContent = targetElt.textContent;
296 | else if (k === "innerHTML") granular.innerHTML = targetElt.innerHTML;
297 | else if (k === "className") granular.className = targetElt.className;
298 | else if (k.startsWith("data-")) granular[k] = targetElt.dataset[k.slice(5)];
299 | else if (k in targetElt) granular[k] = targetElt[k];
300 | });
301 | const snapshotData = {
302 | innerHTML: targetElt.innerHTML,
303 | className: targetElt.className,
304 | attributes,
305 | dataset,
306 | granular,
307 | config,
308 | token
309 | };
310 | snapshots.set(targetElt, snapshotData);
311 | },
312 | applyOptimistic: function(targetElt, sourceElt, config) {
313 | let optimisticTarget = targetElt;
314 | if (config.target) {
315 | const resolved = resolveTargetChain(sourceElt, config.target);
316 | if (resolved) optimisticTarget = resolved;
317 | }
318 | if (config.template) {
319 | const template = this.getTemplate(config.template);
320 | if (template) {
321 | const context = config && typeof config.context === "object" ? config.context : {};
322 | const content = interpolateTemplate(template, sourceElt, context);
323 | if (config.swap === "beforeend") {
324 | optimisticTarget.insertAdjacentHTML("beforeend", content);
325 | } else if (config.swap === "afterbegin") {
326 | optimisticTarget.insertAdjacentHTML("afterbegin", content);
327 | } else {
328 | optimisticTarget.innerHTML = content;
329 | processWithHtmxIfAvailable(optimisticTarget);
330 | }
331 | } else if (typeof config.template === "string" && config.template.startsWith("#")) {
332 | console.warn("[hx-optimistic] Template selector did not resolve:", config.template);
333 | }
334 | } else if (config.values) {
335 | this.applyValues(optimisticTarget, config.values, sourceElt);
336 | }
337 | },
338 | showError: function(targetElt, config, evt) {
339 | if (targetElt.dataset.hxOptimisticErrorShown) return;
340 | targetElt.dataset.hxOptimisticErrorShown = "true";
341 | if (config.errorTemplate) {
342 | const template = this.getTemplate(config.errorTemplate);
343 | if (template) {
344 | const base = config && typeof config.context === "object" ? config.context : {};
345 | const errorData = Object.assign({}, base, {
346 | status: evt.detail?.xhr?.status || 0,
347 | statusText: evt.detail?.xhr?.statusText || "Network Error",
348 | error: evt.detail?.error || "Request failed"
349 | });
350 | const source = evt.detail?.elt || evt.target;
351 | const content = interpolateTemplate(template, source, errorData);
352 | if (config.errorMode === "append") {
353 | const errorEl = document.createElement("div");
354 | errorEl.className = "hx-optimistic-error-message";
355 | errorEl.innerHTML = content;
356 | targetElt.appendChild(errorEl);
357 | } else {
358 | targetElt.innerHTML = content;
359 | }
360 | } else if (typeof config.errorTemplate === "string" && config.errorTemplate.startsWith("#")) {
361 | console.warn("[hx-optimistic] Error template selector did not resolve:", config.errorTemplate);
362 | }
363 | } else if (config.errorMessage) {
364 | if (config.errorMode === "append") {
365 | const errorEl = document.createElement("div");
366 | errorEl.className = "hx-optimistic-error-message";
367 | errorEl.textContent = config.errorMessage;
368 | targetElt.appendChild(errorEl);
369 | } else {
370 | targetElt.textContent = config.errorMessage;
371 | }
372 | }
373 | },
374 | revert: function(targetElt, expectedToken) {
375 | const snapshot = snapshots.get(targetElt);
376 | if (!snapshot) return;
377 | if (expectedToken !== void 0 && snapshot.token !== expectedToken) return;
378 | setOptimisticStateClass(targetElt, "reverting");
379 | if (snapshot.innerHTML !== void 0) targetElt.innerHTML = snapshot.innerHTML;
380 | if (snapshot.className !== void 0) targetElt.className = snapshot.className;
381 | try {
382 | Array.from(targetElt.getAttributeNames()).forEach((n) => targetElt.removeAttribute(n));
383 | Object.entries(snapshot.attributes || {}).forEach(([n, v]) => targetElt.setAttribute(n, v));
384 | } catch (_) {
385 | }
386 | try {
387 | Object.keys(targetElt.dataset || {}).forEach((k) => delete targetElt.dataset[k]);
388 | Object.assign(targetElt.dataset, snapshot.dataset || {});
389 | } catch (_) {
390 | }
391 | snapshots.delete(targetElt);
392 | tokens.delete(targetElt);
393 | this.cleanup(targetElt);
394 | processWithHtmxIfAvailable(targetElt);
395 | const toFocus = snapshot.focusRestore;
396 | if (toFocus && document.contains(toFocus)) {
397 | try {
398 | toFocus.focus();
399 | } catch (_) {
400 | }
401 | }
402 | try {
403 | htmx2.trigger && htmx2.trigger(targetElt, "optimistic:reverted", { config: snapshot.config });
404 | } catch (_) {
405 | }
406 | },
407 | cleanup: function(target) {
408 | if (!target) return;
409 | setOptimisticStateClass(target, "clean");
410 | target.querySelectorAll(".hx-optimistic-error-message").forEach((msg) => msg.remove());
411 | removeOptimisticDatasetAttributes(target);
412 | const snap = snapshots.get(target);
413 | if (snap && snap.config && snap.config.class) {
414 | removeCustomOptimisticClass(target, snap.config);
415 | }
416 | if (snap) snapshots.delete(target);
417 | },
418 | getTemplate: function(templateId) {
419 | if (templateId.startsWith("#")) {
420 | const template = document.querySelector(templateId);
421 | return template ? template.innerHTML : null;
422 | }
423 | return templateId;
424 | },
425 | applyValues: function(targetElt, values, sourceElt) {
426 | Object.entries(values).forEach(([key, value]) => {
427 | const evaluated = interpolateTemplate(value, sourceElt);
428 | if (key === "textContent") targetElt.textContent = evaluated;
429 | else if (key === "innerHTML") targetElt.innerHTML = evaluated;
430 | else if (key === "className") targetElt.className = evaluated;
431 | else if (key.startsWith("data-")) targetElt.dataset[key.slice(5)] = evaluated;
432 | else targetElt[key] = evaluated;
433 | });
434 | }
435 | };
436 | }
437 |
438 | // src/index.js
439 | (function() {
440 | "use strict";
441 | function define() {
442 | if (typeof htmx !== "undefined") {
443 | htmx.defineExtension("optimistic", createExtension(htmx));
444 | }
445 | }
446 | if (typeof htmx !== "undefined") {
447 | define();
448 | } else if (document.readyState === "loading") {
449 | document.addEventListener("DOMContentLoaded", function() {
450 | if (typeof htmx !== "undefined") define();
451 | });
452 | }
453 | })();
454 | })();
455 |
--------------------------------------------------------------------------------
/hx-optimistic.min.js:
--------------------------------------------------------------------------------
1 | (()=>{var t="hx-optimistic",e="hx-optimistic-error",r="hx-optimistic-reverting",n="optimistic";function s(t,e){let r=t;for(;r;){const t=r.querySelector(e);if(t)return t;r=r.parentElement}return null}function i(t,e,r={}){return"string"!=typeof t?t:t.replace(/\${([^}]+)}/g,(t,n)=>{if(n=n.trim(),void 0!==r[n])return r[n];if(!e)return t;if("this.value"===n){if(void 0!==e.value)return e.value;if("FORM"===e.tagName){const t=e.querySelector("input, textarea, select");if(t?.value)return t.value}}if("FORM"===e.tagName){const t={textarea:"textarea",email:'input[type="email"]',password:'input[type="password"]',text:'input[type="text"], input:not([type])',url:'input[type="url"]',tel:'input[type="tel"]',search:'input[type="search"]'}[n]||`[name="${n}"]`,r=e.querySelector(t);if(r?.value)return r.value}if("this.textContent"===n)return e.textContent||t;if(n.startsWith("this.dataset.")){const r=n.slice(13);return e.dataset[r]||t}if(n.startsWith("data:")){const r=n.slice(5).replace(/-([a-z])/g,(t,e)=>e.toUpperCase());return e.dataset[r]||t}if(n.startsWith("attr:")){const r=n.slice(5);return e.getAttribute(r)||t}return(n.includes(".")||n.includes(":"))&&console.warn(`[hx-optimistic] Unresolved interpolation pattern: \${${n}}`,"\nSupported patterns:","\n ${this.value} - element value","\n ${this.textContent} - element text content","\n ${this.dataset.key} - data attribute","\n ${data:key} - data attribute shorthand","\n ${attr:name} - any attribute","\n ${textarea}, ${email}, ${password}, etc. - form field by type","\n ${fieldName} - form field by name","\n ${status}, ${statusText}, ${error} - error context","\nSee documentation for details."),t})}function a(t,e){if(!e||"this"===e)return t;const r=String(e).trim(),n=r.split(/\s+/);if(["closest","find","next","previous"].some(t=>r.startsWith(t))){let e=t;for(let t=0;t0&&setTimeout(()=>this.revert(f,m),p.delay)},snapshot:function(t,e,n,s){const i=Array.from(t.attributes).reduce((t,{name:e,value:r})=>(t[e]=r,t),{}),a={...t.dataset},o=Array.isArray(n.snapshot)&&n.snapshot.length>0?n.snapshot:["innerHTML","className"],l={};o.forEach(e=>{"textContent"===e?l.textContent=t.textContent:"innerHTML"===e?l.innerHTML=t.innerHTML:"className"===e?l.className=t.className:e.startsWith("data-")?l[e]=t.dataset[e.slice(5)]:e in t&&(l[e]=t[e])});const c={innerHTML:t.innerHTML,className:t.className,attributes:i,dataset:a,granular:l,config:n,token:s};r.set(t,c)},applyOptimistic:function(t,e,r){let n=t;if(r.target){const t=a(e,r.target);t&&(n=t)}if(r.template){const t=this.getTemplate(r.template);if(t){const s=i(t,e,r&&"object"==typeof r.context?r.context:{});"beforeend"===r.swap?n.insertAdjacentHTML("beforeend",s):"afterbegin"===r.swap?n.insertAdjacentHTML("afterbegin",s):(n.innerHTML=s,l(n))}else"string"==typeof r.template&&r.template.startsWith("#")&&console.warn("[hx-optimistic] Template selector did not resolve:",r.template)}else r.values&&this.applyValues(n,r.values,e)},showError:function(t,e,r){if(!t.dataset.hxOptimisticErrorShown)if(t.dataset.hxOptimisticErrorShown="true",e.errorTemplate){const n=this.getTemplate(e.errorTemplate);if(n){const s=e&&"object"==typeof e.context?e.context:{},a=Object.assign({},s,{status:r.detail?.xhr?.status||0,statusText:r.detail?.xhr?.statusText||"Network Error",error:r.detail?.error||"Request failed"}),o=i(n,r.detail?.elt||r.target,a);if("append"===e.errorMode){const e=document.createElement("div");e.className="hx-optimistic-error-message",e.innerHTML=o,t.appendChild(e)}else t.innerHTML=o}else"string"==typeof e.errorTemplate&&e.errorTemplate.startsWith("#")&&console.warn("[hx-optimistic] Error template selector did not resolve:",e.errorTemplate)}else if(e.errorMessage)if("append"===e.errorMode){const r=document.createElement("div");r.className="hx-optimistic-error-message",r.textContent=e.errorMessage,t.appendChild(r)}else t.textContent=e.errorMessage},revert:function(e,n){const i=r.get(e);if(!i)return;if(void 0!==n&&i.token!==n)return;o(e,"reverting"),void 0!==i.innerHTML&&(e.innerHTML=i.innerHTML),void 0!==i.className&&(e.className=i.className);try{Array.from(e.getAttributeNames()).forEach(t=>e.removeAttribute(t)),Object.entries(i.attributes||{}).forEach(([t,r])=>e.setAttribute(t,r))}catch(t){}try{Object.keys(e.dataset||{}).forEach(t=>delete e.dataset[t]),Object.assign(e.dataset,i.dataset||{})}catch(t){}r.delete(e),s.delete(e),this.cleanup(e),l(e);const a=i.focusRestore;if(a&&document.contains(a))try{a.focus()}catch(t){}try{t.trigger&&t.trigger(e,"optimistic:reverted",{config:i.config})}catch(t){}},cleanup:function(t){if(!t)return;o(t,"clean"),t.querySelectorAll(".hx-optimistic-error-message").forEach(t=>t.remove()),function(t){t?.dataset&&Object.keys(t.dataset).filter(t=>t.startsWith("hxOptimistic")).forEach(e=>delete t.dataset[e])}(t);const e=r.get(t);e&&e.config&&e.config.class&&function(t,e){e&&e.class&&t?.classList&&t.classList.remove(e.class)}(t,e.config),e&&r.delete(t)},getTemplate:function(t){if(t.startsWith("#")){const e=document.querySelector(t);return e?e.innerHTML:null}return t},applyValues:function(t,e,r){Object.entries(e).forEach(([e,n])=>{const s=i(n,r);"textContent"===e?t.textContent=s:"innerHTML"===e?t.innerHTML=s:"className"===e?t.className=s:e.startsWith("data-")?t.dataset[e.slice(5)]=s:t[e]=s})}}}!function(){"use strict";function t(){"undefined"!=typeof htmx&&htmx.defineExtension("optimistic",u(htmx))}"undefined"!=typeof htmx?t():"loading"===document.readyState&&document.addEventListener("DOMContentLoaded",function(){"undefined"!=typeof htmx&&t()})}()})();
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "hx-optimistic",
3 | "version": "1.0.12",
4 | "description": "HTMX extension for optimistic UI updates with automatic rollback on errors",
5 | "main": "hx-optimistic.js",
6 | "types": "hx-optimistic.d.ts",
7 | "files": [
8 | "hx-optimistic.js",
9 | "hx-optimistic.min.js",
10 | "hx-optimistic.d.ts",
11 | "README.md"
12 | ],
13 | "scripts": {
14 | "test": "npm run build && vitest",
15 | "test:ui": "vitest --ui",
16 | "test:run": "npm run build && vitest run",
17 | "test:coverage": "npm run build && vitest run --coverage",
18 | "test:all": "npm run test:run",
19 | "build": "esbuild src/index.js --bundle --format=iife --outfile=hx-optimistic.js && npm run minify",
20 | "minify": "npx terser hx-optimistic.js -o hx-optimistic.min.js --compress --mangle",
21 | "prepublishOnly": "npm run build && npm run test:run"
22 | },
23 | "keywords": [
24 | "htmx",
25 | "extension",
26 | "optimistic",
27 | "ui",
28 | "updates",
29 | "rollback",
30 | "javascript",
31 | "frontend"
32 | ],
33 | "author": "HTMX Community",
34 | "license": "MIT",
35 | "repository": {
36 | "type": "git",
37 | "url": "https://github.com/lorenseanstewart/hx-optimistic.git"
38 | },
39 | "bugs": {
40 | "url": "https://github.com/lorenseanstewart/hx-optimistic/issues"
41 | },
42 | "homepage": "https://github.com/lorenseanstewart/hx-optimistic#readme",
43 | "peerDependencies": {
44 | "htmx.org": "^1.9.0"
45 | },
46 | "devDependencies": {
47 | "@vitest/coverage-v8": "^1.6.0",
48 | "@vitest/ui": "^1.6.0",
49 | "c8": "^9.1.0",
50 | "esbuild": "^0.21.0",
51 | "husky": "^9.0.11",
52 | "jsdom": "^24.0.0",
53 | "terser": "^5.15.0",
54 | "vitest": "^1.6.0"
55 | },
56 | "engines": {
57 | "node": ">=14"
58 | }
59 | }
--------------------------------------------------------------------------------
/src/config.js:
--------------------------------------------------------------------------------
1 | import { DATASET_OPTIMISTIC_KEY } from './constants.js';
2 |
3 | export function getOptimisticConfig(sourceElt, cacheMap) {
4 | if (!sourceElt?.dataset?.[DATASET_OPTIMISTIC_KEY]) return null;
5 | const raw = sourceElt.dataset[DATASET_OPTIMISTIC_KEY];
6 | const cached = cacheMap.get(sourceElt);
7 | if (cached && cached.__raw === raw) {
8 | return cached.config;
9 | }
10 | let config;
11 | try {
12 | if (raw === 'true' || raw === '') {
13 | config = {};
14 | } else {
15 | config = JSON.parse(raw);
16 | if (typeof config !== 'object' || config === null) {
17 | config = { values: { textContent: raw } };
18 | }
19 | }
20 | } catch (e) {
21 | config = { values: { textContent: raw } };
22 | }
23 | config.delay = config.delay ?? 2000;
24 | config.errorMode = config.errorMode || 'replace';
25 | config.errorMessage = config.errorMessage || 'Request failed';
26 | if (!config.values && !config.template && sourceElt.tagName === 'BUTTON') {
27 | config.values = {
28 | className: (sourceElt.className + ' hx-optimistic-pending').trim(),
29 | };
30 | }
31 | cacheMap.set(sourceElt, { __raw: raw, config });
32 | return config;
33 | }
34 |
35 |
--------------------------------------------------------------------------------
/src/constants.js:
--------------------------------------------------------------------------------
1 | export const CLASS_OPTIMISTIC = 'hx-optimistic';
2 | export const CLASS_ERROR = 'hx-optimistic-error';
3 | export const CLASS_REVERTING = 'hx-optimistic-reverting';
4 | export const ERROR_MESSAGE_CLASS = 'hx-optimistic-error-message';
5 | export const DATASET_OPTIMISTIC_KEY = 'optimistic';
6 |
7 |
--------------------------------------------------------------------------------
/src/extension.js:
--------------------------------------------------------------------------------
1 | import {
2 | interpolateTemplate,
3 | resolveTargetChain,
4 | setOptimisticStateClass,
5 | hasOptimisticConfig,
6 | getTargetFor,
7 | getNextToken,
8 | processWithHtmxIfAvailable,
9 | removeOptimisticDatasetAttributes,
10 | addCustomOptimisticClass,
11 | removeCustomOptimisticClass,
12 | } from './utils.js';
13 | import { getOptimisticConfig } from './config.js';
14 |
15 | export function createExtension(htmx) {
16 | const configCache = new WeakMap();
17 | const snapshots = new WeakMap();
18 | const tokens = new WeakMap();
19 | const sourceTargets = new WeakMap();
20 |
21 | return {
22 | onEvent: function (name, evt) {
23 | if (name === 'htmx:beforeRequest') {
24 | this.handleBeforeRequest(evt);
25 | } else if (
26 | ['htmx:responseError', 'htmx:swapError', 'htmx:timeout', 'htmx:sendError'].includes(name)
27 | ) {
28 | this.handleError(evt);
29 | } else if (name === 'htmx:afterSwap') {
30 | this.cleanup(evt.target);
31 | }
32 | },
33 |
34 | handleBeforeRequest: function (evt) {
35 | const sourceElt = evt.target;
36 | if (!hasOptimisticConfig(sourceElt)) return;
37 |
38 | const config = getOptimisticConfig(sourceElt, configCache);
39 | if (!config) return;
40 |
41 | const targetElt = getTargetFor(sourceElt);
42 | if (!targetElt) {
43 | console.warn('[hx-optimistic] Target element not found for:', sourceElt);
44 | return;
45 | }
46 | sourceTargets.set(sourceElt, targetElt);
47 |
48 | const newToken = getNextToken(targetElt, tokens);
49 |
50 | this.snapshot(targetElt, sourceElt, config, newToken);
51 | this.applyOptimistic(targetElt, sourceElt, config);
52 | setOptimisticStateClass(targetElt, 'optimistic');
53 | addCustomOptimisticClass(targetElt, config);
54 | try { htmx.trigger && htmx.trigger(targetElt, 'optimistic:applied', { config }); } catch (_) {}
55 | },
56 |
57 | handleError: function (evt) {
58 | const sourceElt = evt.detail?.elt || evt.target;
59 | const targetSelector = sourceElt?.getAttribute('hx-target');
60 | const targetElt = (targetSelector ? resolveTargetChain(sourceElt, targetSelector) : null) ||
61 | sourceTargets.get(sourceElt) || sourceElt;
62 | if (!targetElt) return;
63 |
64 | const snapshot = snapshots.get(targetElt);
65 | const config = snapshot?.config || getOptimisticConfig(sourceElt, configCache);
66 | if (!config) return;
67 |
68 | const currentToken = tokens.get(targetElt);
69 | if (snapshot && snapshot.token !== currentToken) return;
70 |
71 | const active = document.activeElement;
72 | if (active && targetElt.contains(active)) {
73 | const existing = snapshots.get(targetElt) || { config, token: currentToken };
74 | existing.focusRestore = active;
75 | snapshots.set(targetElt, existing);
76 | }
77 |
78 | setOptimisticStateClass(targetElt, 'error');
79 | this.showError(targetElt, config, evt);
80 | try {
81 | const errorData = {
82 | status: evt.detail?.xhr?.status || 0,
83 | statusText: evt.detail?.xhr?.statusText || 'Network Error',
84 | error: evt.detail?.error || 'Request failed',
85 | };
86 | htmx.trigger && htmx.trigger(targetElt, 'optimistic:error', { config, detail: errorData });
87 | } catch (_) {}
88 |
89 | if (config.delay > 0) {
90 | setTimeout(() => this.revert(targetElt, currentToken), config.delay);
91 | }
92 | },
93 |
94 | snapshot: function (targetElt, sourceElt, config, token) {
95 | const attributes = Array.from(targetElt.attributes).reduce((acc, { name, value }) => { acc[name] = value; return acc; }, {});
96 | const dataset = { ...targetElt.dataset };
97 | const pickKeys = Array.isArray(config.snapshot) && config.snapshot.length > 0 ? config.snapshot : ['innerHTML', 'className'];
98 | const granular = {};
99 | pickKeys.forEach((k) => {
100 | if (k === 'textContent') granular.textContent = targetElt.textContent;
101 | else if (k === 'innerHTML') granular.innerHTML = targetElt.innerHTML;
102 | else if (k === 'className') granular.className = targetElt.className;
103 | else if (k.startsWith('data-')) granular[k] = targetElt.dataset[k.slice(5)];
104 | else if (k in targetElt) granular[k] = targetElt[k];
105 | });
106 | const snapshotData = {
107 | innerHTML: targetElt.innerHTML,
108 | className: targetElt.className,
109 | attributes,
110 | dataset,
111 | granular,
112 | config: config,
113 | token: token,
114 | };
115 | snapshots.set(targetElt, snapshotData);
116 | },
117 |
118 | applyOptimistic: function (targetElt, sourceElt, config) {
119 | let optimisticTarget = targetElt;
120 | if (config.target) {
121 | const resolved = resolveTargetChain(sourceElt, config.target);
122 | if (resolved) optimisticTarget = resolved;
123 | }
124 | if (config.template) {
125 | const template = this.getTemplate(config.template);
126 | if (template) {
127 | const context = (config && typeof config.context === 'object') ? config.context : {};
128 | const content = interpolateTemplate(template, sourceElt, context);
129 | if (config.swap === 'beforeend') {
130 | optimisticTarget.insertAdjacentHTML('beforeend', content);
131 | } else if (config.swap === 'afterbegin') {
132 | optimisticTarget.insertAdjacentHTML('afterbegin', content);
133 | } else {
134 | optimisticTarget.innerHTML = content;
135 | processWithHtmxIfAvailable(optimisticTarget);
136 | }
137 | } else if (typeof config.template === 'string' && config.template.startsWith('#')) {
138 | console.warn('[hx-optimistic] Template selector did not resolve:', config.template);
139 | }
140 | } else if (config.values) {
141 | this.applyValues(optimisticTarget, config.values, sourceElt);
142 | }
143 | },
144 |
145 | showError: function (targetElt, config, evt) {
146 | if (targetElt.dataset.hxOptimisticErrorShown) return;
147 | targetElt.dataset.hxOptimisticErrorShown = 'true';
148 | if (config.errorTemplate) {
149 | const template = this.getTemplate(config.errorTemplate);
150 | if (template) {
151 | const base = (config && typeof config.context === 'object') ? config.context : {};
152 | const errorData = Object.assign({}, base, {
153 | status: evt.detail?.xhr?.status || 0,
154 | statusText: evt.detail?.xhr?.statusText || 'Network Error',
155 | error: evt.detail?.error || 'Request failed',
156 | });
157 | const source = evt.detail?.elt || evt.target;
158 | const content = interpolateTemplate(template, source, errorData);
159 | if (config.errorMode === 'append') {
160 | const errorEl = document.createElement('div');
161 | errorEl.className = 'hx-optimistic-error-message';
162 | errorEl.innerHTML = content;
163 | targetElt.appendChild(errorEl);
164 | } else {
165 | targetElt.innerHTML = content;
166 | }
167 | } else if (typeof config.errorTemplate === 'string' && config.errorTemplate.startsWith('#')) {
168 | console.warn('[hx-optimistic] Error template selector did not resolve:', config.errorTemplate);
169 | }
170 | } else if (config.errorMessage) {
171 | if (config.errorMode === 'append') {
172 | const errorEl = document.createElement('div');
173 | errorEl.className = 'hx-optimistic-error-message';
174 | errorEl.textContent = config.errorMessage;
175 | targetElt.appendChild(errorEl);
176 | } else {
177 | targetElt.textContent = config.errorMessage;
178 | }
179 | }
180 | },
181 |
182 | revert: function (targetElt, expectedToken) {
183 | const snapshot = snapshots.get(targetElt);
184 | if (!snapshot) return;
185 | if (expectedToken !== undefined && snapshot.token !== expectedToken) return;
186 | setOptimisticStateClass(targetElt, 'reverting');
187 | if (snapshot.innerHTML !== undefined) targetElt.innerHTML = snapshot.innerHTML;
188 | if (snapshot.className !== undefined) targetElt.className = snapshot.className;
189 | try {
190 | Array.from(targetElt.getAttributeNames()).forEach((n) => targetElt.removeAttribute(n));
191 | Object.entries(snapshot.attributes || {}).forEach(([n, v]) => targetElt.setAttribute(n, v));
192 | } catch (_) {}
193 | try {
194 | Object.keys(targetElt.dataset || {}).forEach((k) => delete targetElt.dataset[k]);
195 | Object.assign(targetElt.dataset, snapshot.dataset || {});
196 | } catch (_) {}
197 | snapshots.delete(targetElt);
198 | tokens.delete(targetElt);
199 | this.cleanup(targetElt);
200 | // Always reprocess after revert to restore htmx functionality
201 | processWithHtmxIfAvailable(targetElt);
202 | const toFocus = snapshot.focusRestore;
203 | if (toFocus && document.contains(toFocus)) {
204 | try { toFocus.focus(); } catch (_) {}
205 | }
206 | try { htmx.trigger && htmx.trigger(targetElt, 'optimistic:reverted', { config: snapshot.config }); } catch (_) {}
207 | },
208 |
209 | cleanup: function (target) {
210 | if (!target) return;
211 | setOptimisticStateClass(target, 'clean');
212 | target.querySelectorAll('.hx-optimistic-error-message').forEach((msg) => msg.remove());
213 | removeOptimisticDatasetAttributes(target);
214 | const snap = snapshots.get(target);
215 | if (snap && snap.config && snap.config.class) {
216 | removeCustomOptimisticClass(target, snap.config);
217 | }
218 | if (snap) snapshots.delete(target);
219 | },
220 |
221 | getTemplate: function (templateId) {
222 | if (templateId.startsWith('#')) {
223 | const template = document.querySelector(templateId);
224 | return template ? template.innerHTML : null;
225 | }
226 | return templateId;
227 | },
228 |
229 | applyValues: function (targetElt, values, sourceElt) {
230 | Object.entries(values).forEach(([key, value]) => {
231 | const evaluated = interpolateTemplate(value, sourceElt);
232 | if (key === 'textContent') targetElt.textContent = evaluated;
233 | else if (key === 'innerHTML') targetElt.innerHTML = evaluated;
234 | else if (key === 'className') targetElt.className = evaluated;
235 | else if (key.startsWith('data-')) targetElt.dataset[key.slice(5)] = evaluated;
236 | else targetElt[key] = evaluated;
237 | });
238 | },
239 | };
240 | }
241 |
242 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import { createExtension } from './extension.js';
2 |
3 | (function () {
4 | 'use strict';
5 | function define() {
6 | if (typeof htmx !== 'undefined') {
7 | htmx.defineExtension('optimistic', createExtension(htmx));
8 | }
9 | }
10 | if (typeof htmx !== 'undefined') {
11 | define();
12 | } else if (document.readyState === 'loading') {
13 | document.addEventListener('DOMContentLoaded', function () {
14 | if (typeof htmx !== 'undefined') define();
15 | });
16 | }
17 | })();
18 |
19 |
20 |
--------------------------------------------------------------------------------
/src/utils.js:
--------------------------------------------------------------------------------
1 | import { CLASS_OPTIMISTIC, CLASS_ERROR, CLASS_REVERTING, DATASET_OPTIMISTIC_KEY } from './constants.js';
2 |
3 | export function findClosestInAncestorSubtrees(startElt, selector) {
4 | let node = startElt;
5 | while (node) {
6 | const match = node.querySelector(selector);
7 | if (match) return match;
8 | node = node.parentElement;
9 | }
10 | return null;
11 | }
12 |
13 | export function interpolateTemplate(str, sourceElt, data = {}) {
14 | if (typeof str !== 'string') return str;
15 | return str.replace(/\${([^}]+)}/g, (match, expr) => {
16 | expr = expr.trim();
17 | if (data[expr] !== undefined) {
18 | return data[expr];
19 | }
20 | if (!sourceElt) return match;
21 | if (expr === 'this.value') {
22 | if (sourceElt.value !== undefined) return sourceElt.value;
23 | if (sourceElt.tagName === 'FORM') {
24 | const input = sourceElt.querySelector('input, textarea, select');
25 | if (input?.value) return input.value;
26 | }
27 | }
28 | if (sourceElt.tagName === 'FORM') {
29 | const selectors = {
30 | textarea: 'textarea',
31 | email: 'input[type="email"]',
32 | password: 'input[type="password"]',
33 | text: 'input[type="text"], input:not([type])',
34 | url: 'input[type="url"]',
35 | tel: 'input[type="tel"]',
36 | search: 'input[type="search"]',
37 | };
38 | const selector = selectors[expr] || `[name="${expr}"]`;
39 | const field = sourceElt.querySelector(selector);
40 | if (field?.value) return field.value;
41 | }
42 | if (expr === 'this.textContent') return sourceElt.textContent || match;
43 | if (expr.startsWith('this.dataset.')) {
44 | const key = expr.slice(13);
45 | return sourceElt.dataset[key] || match;
46 | }
47 | if (expr.startsWith('data:')) {
48 | const key = expr.slice(5);
49 | const camelKey = key.replace(/-([a-z])/g, (m, l) => l.toUpperCase());
50 | return sourceElt.dataset[camelKey] || match;
51 | }
52 | if (expr.startsWith('attr:')) {
53 | const name = expr.slice(5);
54 | return sourceElt.getAttribute(name) || match;
55 | }
56 | if (expr.includes('.') || expr.includes(':')) {
57 | console.warn(
58 | `[hx-optimistic] Unresolved interpolation pattern: \${${expr}}`,
59 | '\nSupported patterns:',
60 | '\n ${this.value} - element value',
61 | '\n ${this.textContent} - element text content',
62 | '\n ${this.dataset.key} - data attribute',
63 | '\n ${data:key} - data attribute shorthand',
64 | '\n ${attr:name} - any attribute',
65 | '\n ${textarea}, ${email}, ${password}, etc. - form field by type',
66 | '\n ${fieldName} - form field by name',
67 | '\n ${status}, ${statusText}, ${error} - error context',
68 | '\nSee documentation for details.'
69 | );
70 | }
71 | return match;
72 | });
73 | }
74 |
75 | export function resolveTargetChain(sourceElt, targetSelector) {
76 | if (!targetSelector || targetSelector === 'this') {
77 | return sourceElt;
78 | }
79 | const selector = String(targetSelector).trim();
80 | const ops = selector.split(/\s+/);
81 | if (['closest', 'find', 'next', 'previous'].some((op) => selector.startsWith(op))) {
82 | let context = sourceElt;
83 | for (let i = 0; i < ops.length; ) {
84 | const op = ops[i++];
85 | const sel = ops[i++] || '';
86 | if (op === 'closest') {
87 | let candidate = context ? context.closest(sel) : null;
88 | if (!candidate) {
89 | candidate = findClosestInAncestorSubtrees(context, sel);
90 | }
91 | context = candidate;
92 | if (!context) return null;
93 | } else if (op === 'find') {
94 | if (!context) return null;
95 | let found = context.querySelector(sel);
96 | if (!found) {
97 | let ancestor = context.parentElement;
98 | while (ancestor && !found) {
99 | found = ancestor.querySelector(sel);
100 | if (found) break;
101 | ancestor = ancestor.parentElement;
102 | }
103 | }
104 | context = found || null;
105 | if (!context) return null;
106 | } else if (op === 'next') {
107 | if (!context) return null;
108 | let next = context.nextElementSibling;
109 | let match = null;
110 | while (next) {
111 | if (next.matches(sel)) {
112 | match = next;
113 | break;
114 | }
115 | next = next.nextElementSibling;
116 | }
117 | context = match;
118 | if (!context) return null;
119 | } else if (op === 'previous') {
120 | if (!context) return null;
121 | let prev = context.previousElementSibling;
122 | let match = null;
123 | while (prev) {
124 | if (prev.matches(sel)) {
125 | match = prev;
126 | break;
127 | }
128 | prev = prev.previousElementSibling;
129 | }
130 | context = match;
131 | if (!context) return null;
132 | } else {
133 | return null;
134 | }
135 | }
136 | return context;
137 | }
138 | return document.querySelector(selector);
139 | }
140 |
141 | export function setOptimisticStateClass(target, state) {
142 | if (!target?.classList) return;
143 | target.classList.remove(CLASS_OPTIMISTIC, CLASS_ERROR, CLASS_REVERTING);
144 | if (state === 'optimistic') target.classList.add(CLASS_OPTIMISTIC);
145 | else if (state === 'error') target.classList.add(CLASS_ERROR);
146 | else if (state === 'reverting') target.classList.add(CLASS_REVERTING);
147 | }
148 |
149 | export function hasOptimisticConfig(element) {
150 | return Boolean(element?.dataset?.[DATASET_OPTIMISTIC_KEY]);
151 | }
152 |
153 | export function getTargetFor(sourceElt) {
154 | const targetSelector = sourceElt.getAttribute('hx-target');
155 | if (!targetSelector) return sourceElt;
156 | return resolveTargetChain(sourceElt, targetSelector);
157 | }
158 |
159 | export function getNextToken(targetElt, tokenMap) {
160 | const currentToken = tokenMap.get(targetElt) || 0;
161 | const newToken = currentToken + 1;
162 | tokenMap.set(targetElt, newToken);
163 | return newToken;
164 | }
165 |
166 | export function processWithHtmxIfAvailable(element) {
167 | if (typeof htmx !== 'undefined' && typeof htmx.process === 'function') {
168 | htmx.process(element);
169 | }
170 | }
171 |
172 | export function removeOptimisticDatasetAttributes(target) {
173 | if (!target?.dataset) return;
174 | Object.keys(target.dataset)
175 | .filter((key) => key.startsWith('hxOptimistic'))
176 | .forEach((key) => delete target.dataset[key]);
177 | }
178 |
179 | export function addCustomOptimisticClass(target, config) {
180 | if (config && config.class && target?.classList) {
181 | target.classList.add(config.class);
182 | }
183 | }
184 |
185 | export function removeCustomOptimisticClass(target, config) {
186 | if (config && config.class && target?.classList) {
187 | target.classList.remove(config.class);
188 | }
189 | }
190 |
191 |
--------------------------------------------------------------------------------
/test-results/.last-run.json:
--------------------------------------------------------------------------------
1 | {
2 | "status": "passed",
3 | "failedTests": []
4 | }
--------------------------------------------------------------------------------
/tests/README.md:
--------------------------------------------------------------------------------
1 | # hx-optimistic Testing
2 |
3 | This directory contains comprehensive tests for the hx-optimistic extension.
4 |
5 | ## Test Structure
6 |
7 | ```
8 | tests/
9 | ├── unit/ # Pure function tests (fast)
10 | │ ├── interpolation.test.js
11 | │ └── arithmetic.test.js
12 | ├── integration/ # HTMX integration tests
13 | │ ├── basic-updates.test.js
14 | │ └── error-handling.test.js
15 | ├── e2e/ # Full browser tests
16 | │ └── demo.test.js
17 | ├── fixtures/ # Test HTML templates
18 | └── helpers/ # Test utilities
19 | ├── setup.js
20 | └── test-utils.js
21 | ```
22 |
23 | ## Running Tests
24 |
25 | ### All Tests
26 | ```bash
27 | npm test # Run unit & integration tests in watch mode
28 | npm run test:run # Run tests once
29 | npm run test:all # Run unit, integration, and E2E tests
30 | ```
31 |
32 | ### Specific Test Types
33 | ```bash
34 | npm run test:coverage # Run with coverage report
35 | npm run test:ui # Open Vitest UI
36 | npm run test:e2e # Run Playwright E2E tests
37 | npm run test:browser # Open manual test page
38 | ```
39 |
40 | ### Manual Testing
41 | ```bash
42 | npm run test:browser # Opens test-v1.html for manual testing
43 | ```
44 |
45 | ## Test Categories
46 |
47 | ### Unit Tests
48 | - Test pure functions without DOM/HTMX
49 | - Interpolation patterns
50 | - Arithmetic evaluation
51 | - Config normalization
52 |
53 | ### Integration Tests
54 | - Test HTMX event interactions
55 | - WeakMap storage behavior
56 | - Error handling flows
57 | - Template rendering
58 |
59 | ### E2E Tests
60 | - Real browser scenarios
61 | - Demo functionality
62 | - Memory leak detection
63 | - Cross-browser compatibility
64 |
65 | ## Writing Tests
66 |
67 | ### Unit Test Example
68 | ```javascript
69 | import { describe, it, expect } from 'vitest';
70 |
71 | describe('Feature', () => {
72 | it('should work correctly', () => {
73 | // Test pure function behavior
74 | expect(result).toBe(expected);
75 | });
76 | });
77 | ```
78 |
79 | ### Integration Test Example
80 | ```javascript
81 | import { createOptimisticElement, triggerHtmxEvent } from '../helpers/test-utils.js';
82 |
83 | it('should handle HTMX events', async () => {
84 | const element = createOptimisticElement(
85 | 'Click ',
86 | { values: { textContent: 'Loading...' } }
87 | );
88 |
89 | triggerHtmxEvent(element, 'htmx:beforeRequest');
90 | expect(element.textContent).toBe('Loading...');
91 | });
92 | ```
93 |
94 | ### E2E Test Example
95 | ```javascript
96 | import { test, expect } from '@playwright/test';
97 |
98 | test('demo should work', async ({ page }) => {
99 | await page.goto('/demo');
100 | await page.click('button[data-optimistic]');
101 | await expect(page.locator('.hx-optimistic')).toBeVisible();
102 | });
103 | ```
104 |
105 | ## Coverage
106 |
107 | Coverage reports are generated in the `coverage/` directory:
108 | - `coverage/index.html` - Visual report
109 | - `coverage/lcov.info` - LCOV format for CI
110 |
111 | Target coverage thresholds:
112 | - Branches: 85%
113 | - Functions: 90%
114 | - Lines: 90%
115 | - Statements: 90%
116 |
117 | ## CI/CD
118 |
119 | Tests run automatically on:
120 | - Push to main/develop branches
121 | - Pull requests
122 | - Multiple Node.js versions (16, 18, 20)
123 | - Multiple browsers (Chrome, Firefox, Safari)
124 |
125 | ## Manual Testing
126 |
127 | For quick manual testing during development:
128 |
129 | 1. Open `test-v1.html` in your browser
130 | 2. Use `test.html` for legacy comparison
131 | 3. Check browser console for test results
132 | 4. All tests should pass in green
133 |
134 | ## Debugging
135 |
136 | ### Vitest Debugging
137 | ```bash
138 | npm run test:ui # Visual test runner
139 | ```
140 |
141 | ### Playwright Debugging
142 | ```bash
143 | npx playwright test --debug
144 | npx playwright test --headed
145 | ```
146 |
147 | ### Browser DevTools
148 | Open `test-v1.html` and use browser DevTools to:
149 | - Inspect DOM changes
150 | - Monitor console warnings
151 | - Check network requests
152 | - Debug extension behavior
153 |
154 | ## Test Utilities
155 |
156 | The `helpers/test-utils.js` provides utilities:
157 | - `createOptimisticElement()` - Create test elements
158 | - `triggerHtmxEvent()` - Simulate HTMX events
159 | - `waitFor()` - Wait for conditions
160 | - `nextTick()` - Wait for next event loop
161 | - `createTemplate()` - Create template elements
162 |
163 | ## Performance Testing
164 |
165 | E2E tests include memory leak detection:
166 | - Creates many elements with optimistic config
167 | - Removes elements
168 | - Checks memory usage
169 | - Ensures WeakMaps allow garbage collection
--------------------------------------------------------------------------------
/tests/helpers/setup.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Test setup file for Vitest
3 | * Loads HTMX and the hx-optimistic extension into the test environment
4 | */
5 |
6 | import { beforeAll, afterEach, vi } from 'vitest';
7 | import fs from 'fs';
8 | import path from 'path';
9 | import { fileURLToPath } from 'url';
10 | import vm from 'vm';
11 |
12 | const __filename = fileURLToPath(import.meta.url);
13 | const __dirname = path.dirname(__filename);
14 |
15 | beforeAll(() => {
16 | // Create a comprehensive HTMX mock that matches the real API
17 | global.htmx = {
18 | version: '1.9.10',
19 | config: {
20 | defaultSwapStyle: 'innerHTML',
21 | defaultSwapDelay: 0
22 | },
23 | defineExtension: vi.fn((name, extension) => {
24 | global.htmx.extensions = global.htmx.extensions || {};
25 | global.htmx.extensions[name] = extension;
26 | console.log(`✓ Extension "${name}" registered`);
27 | }),
28 | process: vi.fn((element) => {
29 | // Mock process function that actually processes hx-* attributes
30 | if (element && element.setAttribute) {
31 | // Simulate htmx processing by ensuring hx-ext elements are marked
32 | if (element.getAttribute('hx-ext')) {
33 | element.classList.add('htmx-processed');
34 | }
35 | }
36 | return element;
37 | }),
38 | on: vi.fn(),
39 | trigger: vi.fn(),
40 | find: vi.fn((selector) => document.querySelector(selector)),
41 | findAll: vi.fn((selector) => Array.from(document.querySelectorAll(selector))),
42 | extensions: {},
43 | // Add methods that the extension might call
44 | swap: vi.fn(),
45 | settle: vi.fn(),
46 | values: vi.fn(() => ({})),
47 | closest: vi.fn((element, selector) => element.closest(selector))
48 | };
49 |
50 | // Make htmx available globally (needed for extension)
51 | global.window = global;
52 | global.window.htmx = global.htmx;
53 |
54 | // Load the hx-optimistic extension
55 | const extensionPath = path.join(__dirname, '../../hx-optimistic.js');
56 | const extensionCode = fs.readFileSync(extensionPath, 'utf8');
57 |
58 | try {
59 | vm.runInThisContext(extensionCode, { filename: extensionPath });
60 |
61 | // Verify the extension was loaded
62 | if (!global.htmx.extensions.optimistic) {
63 | throw new Error('hx-optimistic extension was not properly loaded');
64 | }
65 |
66 | console.log('✓ hx-optimistic extension loaded successfully');
67 | console.log('Available extension methods:', Object.keys(global.htmx.extensions.optimistic));
68 | } catch (error) {
69 | console.error('Failed to load extension:', error);
70 | console.error('Extension code length:', extensionCode.length);
71 | throw error;
72 | }
73 | });
74 |
75 | afterEach(() => {
76 | // Clean up DOM after each test
77 | document.body.innerHTML = '';
78 | document.head.innerHTML = '';
79 |
80 | // Reset HTMX mock call history
81 | if (global.htmx && global.htmx.defineExtension) {
82 | global.htmx.defineExtension.mockClear();
83 | global.htmx.process.mockClear();
84 | global.htmx.on && global.htmx.on.mockClear();
85 | global.htmx.trigger && global.htmx.trigger.mockClear();
86 | }
87 |
88 | // Clean up any error attributes set during testing
89 | const elements = document.querySelectorAll('[data-hx-optimistic-error-shown]');
90 | elements.forEach(el => {
91 | el.removeAttribute('data-hx-optimistic-error-shown');
92 | });
93 |
94 | // Clear any test-specific CSS classes
95 | const classElements = document.querySelectorAll('.hx-optimistic, .hx-optimistic-error, .hx-optimistic-reverting');
96 | classElements.forEach(el => {
97 | el.classList.remove('hx-optimistic', 'hx-optimistic-error', 'hx-optimistic-reverting');
98 | });
99 | });
--------------------------------------------------------------------------------
/tests/helpers/test-utils.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Test utility functions for hx-optimistic tests
3 | */
4 |
5 | /**
6 | * Create a test element with optimistic configuration
7 | */
8 | export function createOptimisticElement(html, config = {}) {
9 | const container = document.createElement('div');
10 | container.innerHTML = html;
11 | const element = container.firstElementChild;
12 |
13 | if (config && Object.keys(config).length > 0) {
14 | element.setAttribute('data-optimistic', JSON.stringify(config));
15 | }
16 |
17 | document.body.appendChild(element);
18 | return element;
19 | }
20 |
21 | /**
22 | * Trigger an HTMX event on an element and call extension handlers
23 | */
24 | export function triggerHtmxEvent(element, eventName, detail = {}) {
25 | const eventDetail = {
26 | elt: element,
27 | target: element,
28 | ...detail
29 | };
30 |
31 | const event = new CustomEvent(eventName, {
32 | detail: eventDetail,
33 | bubbles: true,
34 | cancelable: true
35 | });
36 |
37 | // Set target for event
38 | Object.defineProperty(event, 'target', {
39 | value: element,
40 | enumerable: true
41 | });
42 |
43 | // Call the extension's onEvent handler directly
44 | const extension = global.htmx?.extensions?.optimistic;
45 | if (extension && extension.onEvent) {
46 | extension.onEvent(eventName, event);
47 | }
48 |
49 | // Also dispatch the event normally
50 | element.dispatchEvent(event);
51 | return event;
52 | }
53 |
54 | /**
55 | * Create a mock XHR response
56 | */
57 | export function createMockXhr(status = 200, statusText = 'OK', responseText = '') {
58 | return {
59 | status,
60 | statusText,
61 | responseText,
62 | getAllResponseHeaders: () => '',
63 | getResponseHeader: (name) => null
64 | };
65 | }
66 |
67 | /**
68 | * Wait for a condition to be true
69 | */
70 | export async function waitFor(condition, timeout = 1000) {
71 | const startTime = Date.now();
72 | while (!condition()) {
73 | if (Date.now() - startTime > timeout) {
74 | throw new Error('Timeout waiting for condition');
75 | }
76 | await new Promise(resolve => setTimeout(resolve, 10));
77 | }
78 | }
79 |
80 | /**
81 | * Wait for next tick
82 | */
83 | export async function nextTick() {
84 | return new Promise(resolve => setTimeout(resolve, 0));
85 | }
86 |
87 | /**
88 | * Get the optimistic extension instance
89 | */
90 | export function getExtension() {
91 | return global.htmx?.extensions?.optimistic;
92 | }
93 |
94 | /**
95 | * Verify WeakMap usage by checking that no data is stored in element dataset
96 | */
97 | export function verifyWeakMapUsage(element) {
98 | const datasetKeys = Object.keys(element.dataset);
99 | const optimisticKeys = datasetKeys.filter(key =>
100 | key.startsWith('hxOptimistic') && key !== 'hxOptimisticErrorShown'
101 | );
102 |
103 | return {
104 | hasNoWeakMapLeaks: optimisticKeys.length === 0,
105 | leakedKeys: optimisticKeys
106 | };
107 | }
108 |
109 | /**
110 | * Create a template element for testing
111 | */
112 | export function createTemplate(id, content) {
113 | const template = document.createElement('template');
114 | template.id = id;
115 | template.innerHTML = content;
116 | document.body.appendChild(template);
117 | return template;
118 | }
119 |
120 | /**
121 | * Create a form element with various input types for testing
122 | */
123 | export function createTestForm(config = {}) {
124 | const form = document.createElement('form');
125 | form.setAttribute('hx-post', config.endpoint || '/api/test');
126 | form.setAttribute('hx-ext', 'optimistic');
127 |
128 | if (config.optimisticConfig) {
129 | form.setAttribute('data-optimistic', JSON.stringify(config.optimisticConfig));
130 | }
131 |
132 | // Add various input types
133 | const inputs = [
134 | { type: 'text', name: 'username', value: config.username || 'testuser' },
135 | { type: 'email', name: 'email', value: config.email || 'test@example.com' },
136 | { type: 'password', name: 'password', value: config.password || 'secret' },
137 | { type: 'url', name: 'website', value: config.website || 'https://example.com' },
138 | { type: 'tel', name: 'phone', value: config.phone || '555-1234' },
139 | { type: 'search', name: 'query', value: config.query || 'search term' }
140 | ];
141 |
142 | inputs.forEach(inputConfig => {
143 | const input = document.createElement('input');
144 | input.type = inputConfig.type;
145 | input.name = inputConfig.name;
146 | input.value = inputConfig.value;
147 | form.appendChild(input);
148 | });
149 |
150 | // Add textarea
151 | const textarea = document.createElement('textarea');
152 | textarea.name = 'comment';
153 | textarea.value = config.comment || 'This is a test comment';
154 | form.appendChild(textarea);
155 |
156 | // Add select
157 | const select = document.createElement('select');
158 | select.name = 'category';
159 | const option = document.createElement('option');
160 | option.value = 'test';
161 | option.textContent = config.category || 'Test Category';
162 | option.selected = true;
163 | select.appendChild(option);
164 | form.appendChild(select);
165 |
166 | document.body.appendChild(form);
167 | return form;
168 | }
169 |
170 | /**
171 | * Create an element with target resolution testing
172 | */
173 | export function createTargetTestElement(sourceConfig, targetConfig) {
174 | const container = document.createElement('div');
175 | container.className = 'test-container';
176 |
177 | // Create source element
178 | const source = document.createElement(sourceConfig.tagName || 'button');
179 | source.textContent = sourceConfig.text || 'Click me';
180 | source.setAttribute('hx-post', sourceConfig.endpoint || '/api/test');
181 | source.setAttribute('hx-ext', 'optimistic');
182 |
183 | if (sourceConfig.hxTarget) {
184 | source.setAttribute('hx-target', sourceConfig.hxTarget);
185 | }
186 |
187 | if (sourceConfig.optimisticConfig) {
188 | source.setAttribute('data-optimistic', JSON.stringify(sourceConfig.optimisticConfig));
189 | }
190 |
191 | // Add data attributes if specified
192 | if (sourceConfig.dataAttributes) {
193 | Object.entries(sourceConfig.dataAttributes).forEach(([key, value]) => {
194 | source.setAttribute(`data-${key}`, value);
195 | });
196 | }
197 |
198 | container.appendChild(source);
199 |
200 | // Create target element if specified
201 | if (targetConfig) {
202 | const target = document.createElement(targetConfig.tagName || 'div');
203 | target.className = targetConfig.className || 'target';
204 | target.textContent = targetConfig.text || 'Target content';
205 | target.id = targetConfig.id || 'test-target';
206 |
207 | if (targetConfig.parent) {
208 | const parent = document.createElement('div');
209 | parent.className = targetConfig.parent;
210 | parent.appendChild(target);
211 | container.appendChild(parent);
212 | } else {
213 | container.appendChild(target);
214 | }
215 | }
216 |
217 | document.body.appendChild(container);
218 | return { container, source, target: targetConfig ? container.querySelector('.target, #test-target') : null };
219 | }
220 |
221 | /**
222 | * Create a mock error event with detailed properties
223 | */
224 | export function createErrorEvent(element, errorDetails = {}) {
225 | const mockXhr = {
226 | status: errorDetails.status || 500,
227 | statusText: errorDetails.statusText || 'Internal Server Error',
228 | responseText: errorDetails.responseText || 'Server error occurred'
229 | };
230 |
231 | return {
232 | detail: {
233 | elt: element,
234 | target: element,
235 | xhr: mockXhr,
236 | error: errorDetails.error || 'Request failed'
237 | },
238 | bubbles: true,
239 | cancelable: true
240 | };
241 | }
242 |
243 | /**
244 | * Test interpolation by applying values and checking results
245 | */
246 | export function testInterpolation(element, config, expectedValues) {
247 | const extension = getExtension();
248 | if (!extension) {
249 | throw new Error('hx-optimistic extension not loaded');
250 | }
251 |
252 | // Apply the configuration
253 | element.setAttribute('data-optimistic', JSON.stringify(config));
254 |
255 | // Apply optimistic update
256 | extension.applyOptimistic(element, element, config);
257 |
258 | // Check results
259 | const results = {};
260 | Object.entries(expectedValues).forEach(([property, expectedValue]) => {
261 | if (property === 'textContent') {
262 | results[property] = element.textContent;
263 | } else if (property === 'innerHTML') {
264 | results[property] = element.innerHTML;
265 | } else if (property === 'className') {
266 | results[property] = element.className;
267 | } else if (property.startsWith('data-')) {
268 | results[property] = element.dataset[property.slice(5)];
269 | } else {
270 | results[property] = element[property];
271 | }
272 | });
273 |
274 | return results;
275 | }
276 |
277 | /**
278 | * Simulate a complete request lifecycle
279 | */
280 | export async function simulateRequestLifecycle(element, options = {}) {
281 | const extension = getExtension();
282 |
283 | // Step 1: beforeRequest
284 | triggerHtmxEvent(element, 'htmx:beforeRequest');
285 | await nextTick();
286 |
287 | const results = {
288 | beforeRequest: {
289 | hasOptimisticClass: element.classList.contains('hx-optimistic'),
290 | textContent: element.textContent,
291 | innerHTML: element.innerHTML
292 | }
293 | };
294 |
295 | if (options.shouldError) {
296 | // Step 2: Error
297 | const errorEvent = createErrorEvent(element, options.errorDetails);
298 | triggerHtmxEvent(element, 'htmx:responseError', errorEvent.detail);
299 | await nextTick();
300 |
301 | results.afterError = {
302 | hasErrorClass: element.classList.contains('hx-optimistic-error'),
303 | textContent: element.textContent,
304 | innerHTML: element.innerHTML
305 | };
306 |
307 | // Step 3: Revert (if delay specified)
308 | if (options.delay) {
309 | await new Promise(resolve => setTimeout(resolve, options.delay + 50));
310 |
311 | results.afterRevert = {
312 | hasRevertingClass: element.classList.contains('hx-optimistic-reverting'),
313 | hasCleanState: !element.classList.contains('hx-optimistic') &&
314 | !element.classList.contains('hx-optimistic-error'),
315 | textContent: element.textContent,
316 | innerHTML: element.innerHTML
317 | };
318 | }
319 | } else {
320 | // Step 2: Success
321 | triggerHtmxEvent(element, 'htmx:afterSwap');
322 | await nextTick();
323 |
324 | results.afterSuccess = {
325 | hasCleanState: !element.classList.contains('hx-optimistic'),
326 | textContent: element.textContent,
327 | innerHTML: element.innerHTML
328 | };
329 | }
330 |
331 | return results;
332 | }
--------------------------------------------------------------------------------
/tests/integration/basic-updates.test.js:
--------------------------------------------------------------------------------
1 | import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2 | import { createOptimisticElement, triggerHtmxEvent, nextTick, waitFor, simulateRequestLifecycle, verifyWeakMapUsage, createTemplate } from '../helpers/test-utils.js';
3 |
4 | describe('Basic Optimistic Updates Integration', () => {
5 | let element;
6 |
7 | beforeEach(() => {
8 | // Ensure extension is loaded
9 | expect(global.htmx.extensions.optimistic).toBeDefined();
10 | });
11 |
12 | afterEach(() => {
13 | if (element && element.parentNode) {
14 | element.remove();
15 | }
16 | });
17 |
18 | describe('Optimistic state application', () => {
19 | it('should apply optimistic values on beforeRequest', async () => {
20 | element = createOptimisticElement(
21 | 'Click me ',
22 | {
23 | values: {
24 | textContent: 'Loading...',
25 | className: 'btn loading'
26 | }
27 | }
28 | );
29 |
30 | // Trigger beforeRequest event
31 | triggerHtmxEvent(element, 'htmx:beforeRequest');
32 | await nextTick();
33 |
34 | expect(element.textContent).toBe('Loading...');
35 | expect(element.className).toBe('btn loading hx-optimistic');
36 | });
37 |
38 | it('should apply template on beforeRequest', async () => {
39 | // Create a template
40 | const template = document.createElement('template');
41 | template.id = 'loading-template';
42 | template.innerHTML = 'Loading... ';
43 | document.body.appendChild(template);
44 |
45 | element = createOptimisticElement(
46 | 'Click me ',
47 | {
48 | template: '#loading-template'
49 | }
50 | );
51 |
52 | triggerHtmxEvent(element, 'htmx:beforeRequest');
53 | await nextTick();
54 |
55 | expect(element.innerHTML).toBe('Loading... ');
56 | expect(element.classList.contains('hx-optimistic')).toBe(true);
57 |
58 | // Cleanup
59 | template.remove();
60 | });
61 |
62 | it('should handle inline template strings', async () => {
63 | element = createOptimisticElement(
64 | 'Click me ',
65 | {
66 | template: 'Please wait...
'
67 | }
68 | );
69 |
70 | triggerHtmxEvent(element, 'htmx:beforeRequest');
71 | await nextTick();
72 |
73 | expect(element.innerHTML).toBe('Please wait...
');
74 | });
75 | });
76 |
77 | describe('WeakMap storage functionality', () => {
78 | it('should use WeakMaps instead of dataset for state storage', async () => {
79 | element = createOptimisticElement(
80 | 'Original Text ',
81 | {
82 | values: {
83 | textContent: 'Loading...',
84 | className: 'loading'
85 | }
86 | }
87 | );
88 |
89 | const originalText = element.textContent;
90 | const originalClass = element.className;
91 |
92 | triggerHtmxEvent(element, 'htmx:beforeRequest');
93 | await nextTick();
94 |
95 | // Values should be changed
96 | expect(element.textContent).toBe('Loading...');
97 | expect(element.className).toContain('loading');
98 |
99 | // Verify no data stored in dataset (should use WeakMaps)
100 | const verification = verifyWeakMapUsage(element);
101 | expect(verification.hasNoWeakMapLeaks).toBe(true);
102 |
103 | // Trigger error to revert
104 | triggerHtmxEvent(element, 'htmx:responseError', {
105 | xhr: { status: 500, statusText: 'Server Error' },
106 | elt: element
107 | });
108 |
109 | // Wait for revert (default delay)
110 | await new Promise(resolve => setTimeout(resolve, 2100));
111 |
112 | // Should be reverted
113 | expect(element.textContent).toBe(originalText);
114 | expect(element.className).toContain('original-class');
115 |
116 | // Still no dataset pollution
117 | const postRevertVerification = verifyWeakMapUsage(element);
118 | expect(postRevertVerification.hasNoWeakMapLeaks).toBe(true);
119 | });
120 |
121 | it('should automatically snapshot innerHTML and className', async () => {
122 | element = createOptimisticElement(
123 | '',
124 | {
125 | template: 'Loading...
'
126 | }
127 | );
128 |
129 | const originalHTML = element.innerHTML;
130 | const originalClass = element.className;
131 |
132 | triggerHtmxEvent(element, 'htmx:beforeRequest');
133 | await nextTick();
134 |
135 | expect(element.innerHTML).toBe('Loading...
');
136 | expect(element.className).toContain('hx-optimistic');
137 |
138 | // Trigger error
139 | triggerHtmxEvent(element, 'htmx:responseError', {
140 | xhr: { status: 500, statusText: 'Server Error' },
141 | elt: element
142 | });
143 |
144 | // Wait for revert
145 | await new Promise(resolve => setTimeout(resolve, 2100));
146 |
147 | // Complex content should be restored
148 | expect(element.innerHTML).toBe(originalHTML);
149 | // Class should be restored plus htmx-processed added by htmx.process()
150 | expect(element.className).toBe(originalClass + ' htmx-processed');
151 | });
152 | });
153 |
154 | describe('Cleanup on success', () => {
155 | it('should remove optimistic class on afterSwap', async () => {
156 | element = createOptimisticElement(
157 | 'Click me ',
158 | {
159 | values: { textContent: 'Loading...' }
160 | }
161 | );
162 |
163 | triggerHtmxEvent(element, 'htmx:beforeRequest');
164 | await nextTick();
165 |
166 | expect(element.classList.contains('hx-optimistic')).toBe(true);
167 |
168 | // Simulate successful swap
169 | triggerHtmxEvent(element, 'htmx:afterSwap');
170 | await nextTick();
171 |
172 | expect(element.classList.contains('hx-optimistic')).toBe(false);
173 | });
174 | });
175 |
176 | describe('Token-based concurrency control', () => {
177 | it('should handle concurrent requests with tokens', async () => {
178 | element = createOptimisticElement(
179 | 'Click me ',
180 | {
181 | values: { textContent: 'Loading...' },
182 | errorMessage: 'Error',
183 | delay: 100
184 | }
185 | );
186 |
187 | // First request
188 | triggerHtmxEvent(element, 'htmx:beforeRequest');
189 | await nextTick();
190 | expect(element.textContent).toBe('Loading...');
191 |
192 | // Second request before first completes (should get new token)
193 | element.setAttribute('data-optimistic', JSON.stringify({
194 | values: { textContent: 'Loading again...' },
195 | errorMessage: 'Error 2',
196 | delay: 100
197 | }));
198 |
199 | triggerHtmxEvent(element, 'htmx:beforeRequest');
200 | await nextTick();
201 | expect(element.textContent).toBe('Loading again...');
202 |
203 | // First request fails (should be ignored due to token mismatch)
204 | triggerHtmxEvent(element, 'htmx:responseError', {
205 | xhr: { status: 500, statusText: 'Server Error' },
206 | elt: element
207 | });
208 | await nextTick();
209 |
210 | // Should show the second request's error, not the first
211 | expect(element.textContent).toBe('Error 2');
212 |
213 | // Verify no dataset pollution from token management
214 | const verification = verifyWeakMapUsage(element);
215 | expect(verification.hasNoWeakMapLeaks).toBe(true);
216 | });
217 | });
218 |
219 | describe('Custom class support', () => {
220 | it('should apply custom optimistic class', async () => {
221 | element = createOptimisticElement(
222 | 'Click me ',
223 | {
224 | class: 'custom-optimistic-class',
225 | values: { textContent: 'Loading...' }
226 | }
227 | );
228 |
229 | triggerHtmxEvent(element, 'htmx:beforeRequest');
230 | await nextTick();
231 |
232 | expect(element.classList.contains('custom-optimistic-class')).toBe(true);
233 | expect(element.classList.contains('hx-optimistic')).toBe(true); // Default also applied
234 | });
235 | });
236 |
237 | describe('Memory management', () => {
238 | it('should not revert when delay is 0', async () => {
239 | element = createOptimisticElement(
240 | 'Click me ',
241 | {
242 | values: { textContent: 'Loading...' },
243 | errorMessage: 'Error occurred',
244 | delay: 0
245 | }
246 | );
247 |
248 | triggerHtmxEvent(element, 'htmx:beforeRequest');
249 | await nextTick();
250 |
251 | triggerHtmxEvent(element, 'htmx:responseError', {
252 | xhr: { status: 500, statusText: 'Server Error' },
253 | elt: element
254 | });
255 | await nextTick();
256 |
257 | expect(element.textContent).toBe('Error occurred');
258 |
259 | // Wait to ensure no revert happens
260 | await new Promise(resolve => setTimeout(resolve, 500));
261 |
262 | // Should still show error
263 | expect(element.textContent).toBe('Error occurred');
264 | expect(element.classList.contains('hx-optimistic-error')).toBe(true);
265 |
266 | // Verify no memory leaks in dataset
267 | const verification = verifyWeakMapUsage(element);
268 | expect(verification.hasNoWeakMapLeaks).toBe(true);
269 | });
270 | });
271 | });
--------------------------------------------------------------------------------
/tests/integration/error-handling.test.js:
--------------------------------------------------------------------------------
1 | import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2 | import { createOptimisticElement, triggerHtmxEvent, nextTick, createTemplate } from '../helpers/test-utils.js';
3 |
4 | describe('Error Handling Integration', () => {
5 | let element;
6 |
7 | afterEach(() => {
8 | if (element && element.parentNode) {
9 | element.parentNode.removeChild(element);
10 | }
11 | });
12 |
13 | describe('Error message display', () => {
14 | it('should show error message on failure', async () => {
15 | element = createOptimisticElement(
16 | 'Click me ',
17 | {
18 | values: { textContent: 'Loading...' },
19 | errorMessage: 'Request failed',
20 | delay: 100
21 | }
22 | );
23 |
24 | triggerHtmxEvent(element, 'htmx:beforeRequest');
25 | await nextTick();
26 |
27 | triggerHtmxEvent(element, 'htmx:responseError', {
28 | xhr: { status: 500, statusText: 'Internal Server Error' }
29 | });
30 | await nextTick();
31 |
32 | expect(element.textContent).toBe('Request failed');
33 | expect(element.classList.contains('hx-optimistic-error')).toBe(true);
34 | });
35 |
36 | it('should append error message when errorMode is append', async () => {
37 | element = createOptimisticElement(
38 | 'Original content
',
39 | {
40 | errorMessage: 'Error occurred',
41 | errorMode: 'append',
42 | delay: 100
43 | }
44 | );
45 |
46 | triggerHtmxEvent(element, 'htmx:beforeRequest');
47 | await nextTick();
48 |
49 | triggerHtmxEvent(element, 'htmx:responseError', {
50 | xhr: { status: 500, statusText: 'Server Error' }
51 | });
52 | await nextTick();
53 |
54 | const errorMsg = element.querySelector('.hx-optimistic-error-message');
55 | expect(errorMsg).toBeTruthy();
56 | expect(errorMsg.textContent).toBe('Error occurred');
57 | expect(element.textContent).toContain('Original content');
58 | });
59 | });
60 |
61 | describe('Error template rendering', () => {
62 | it('should render error template with variables', async () => {
63 | const template = createTemplate('error-template',
64 | 'Error ${status}: ${statusText} - ${error}
'
65 | );
66 |
67 | element = createOptimisticElement(
68 | 'Click me ',
69 | {
70 | errorTemplate: '#error-template',
71 | delay: 100
72 | }
73 | );
74 |
75 | triggerHtmxEvent(element, 'htmx:beforeRequest');
76 | await nextTick();
77 |
78 | triggerHtmxEvent(element, 'htmx:responseError', {
79 | xhr: { status: 404, statusText: 'Not Found' },
80 | error: 'Resource not found'
81 | });
82 | await nextTick();
83 |
84 | expect(element.innerHTML).toContain('Error 404: Not Found - Resource not found');
85 |
86 | // Cleanup
87 | template.remove();
88 | });
89 |
90 | it('should handle inline error templates', async () => {
91 | element = createOptimisticElement(
92 | 'Click me ',
93 | {
94 | errorTemplate: 'Failed with status ${status} ',
95 | delay: 100
96 | }
97 | );
98 |
99 | triggerHtmxEvent(element, 'htmx:beforeRequest');
100 | await nextTick();
101 |
102 | triggerHtmxEvent(element, 'htmx:responseError', {
103 | xhr: { status: 503, statusText: 'Service Unavailable' }
104 | });
105 | await nextTick();
106 |
107 | expect(element.innerHTML).toBe('Failed with status 503 ');
108 | });
109 | });
110 |
111 | describe('Error state reversion', () => {
112 | it('should revert to original state after delay', async () => {
113 | element = createOptimisticElement(
114 | 'Original Text ',
115 | {
116 | snapshot: ['textContent'],
117 | values: { textContent: 'Loading...' },
118 | errorMessage: 'Failed',
119 | delay: 200
120 | }
121 | );
122 |
123 | const originalText = element.textContent;
124 |
125 | triggerHtmxEvent(element, 'htmx:beforeRequest');
126 | await nextTick();
127 | expect(element.textContent).toBe('Loading...');
128 |
129 | triggerHtmxEvent(element, 'htmx:responseError', {
130 | xhr: { status: 500, statusText: 'Error' }
131 | });
132 | await nextTick();
133 | expect(element.textContent).toBe('Failed');
134 |
135 | // Wait for revert
136 | await new Promise(resolve => setTimeout(resolve, 250));
137 |
138 | expect(element.textContent).toBe(originalText);
139 | expect(element.classList.contains('hx-optimistic-error')).toBe(false);
140 | expect(element.classList.contains('hx-optimistic-reverting')).toBe(false);
141 | });
142 |
143 | it('should apply reverting class during revert', async () => {
144 | element = createOptimisticElement(
145 | 'Click me ',
146 | {
147 | errorMessage: 'Failed',
148 | delay: 100
149 | }
150 | );
151 |
152 | triggerHtmxEvent(element, 'htmx:beforeRequest');
153 | await nextTick();
154 |
155 | triggerHtmxEvent(element, 'htmx:responseError', {
156 | xhr: { status: 500, statusText: 'Error' }
157 | });
158 |
159 | // Check for reverting class during transition
160 | await new Promise(resolve => setTimeout(resolve, 110));
161 |
162 | // The reverting class should be applied during revert
163 | // Note: This might be timing-sensitive in tests
164 | });
165 | });
166 |
167 | describe('Different error event types', () => {
168 | it('should handle htmx:timeout', async () => {
169 | element = createOptimisticElement(
170 | 'Click me ',
171 | {
172 | errorMessage: 'Request timed out',
173 | delay: 100
174 | }
175 | );
176 |
177 | triggerHtmxEvent(element, 'htmx:beforeRequest');
178 | await nextTick();
179 |
180 | triggerHtmxEvent(element, 'htmx:timeout');
181 | await nextTick();
182 |
183 | expect(element.textContent).toBe('Request timed out');
184 | expect(element.classList.contains('hx-optimistic-error')).toBe(true);
185 | });
186 |
187 | it('should handle htmx:sendError', async () => {
188 | element = createOptimisticElement(
189 | 'Click me ',
190 | {
191 | errorMessage: 'Network error',
192 | delay: 100
193 | }
194 | );
195 |
196 | triggerHtmxEvent(element, 'htmx:beforeRequest');
197 | await nextTick();
198 |
199 | triggerHtmxEvent(element, 'htmx:sendError');
200 | await nextTick();
201 |
202 | expect(element.textContent).toBe('Network error');
203 | });
204 |
205 | it('should handle htmx:swapError', async () => {
206 | element = createOptimisticElement(
207 | 'Click me ',
208 | {
209 | errorMessage: 'Swap failed',
210 | delay: 100
211 | }
212 | );
213 |
214 | triggerHtmxEvent(element, 'htmx:beforeRequest');
215 | await nextTick();
216 |
217 | triggerHtmxEvent(element, 'htmx:swapError');
218 | await nextTick();
219 |
220 | expect(element.textContent).toBe('Swap failed');
221 | });
222 | });
223 |
224 | describe('Error handling with templates', () => {
225 | it('should preserve template state on error', async () => {
226 | element = createOptimisticElement(
227 | '',
228 | {
229 | snapshotContent: true,
230 | template: 'Loading...
',
231 | errorTemplate: 'Error occurred
',
232 | delay: 200
233 | }
234 | );
235 |
236 | const originalHTML = element.innerHTML;
237 |
238 | triggerHtmxEvent(element, 'htmx:beforeRequest');
239 | await nextTick();
240 | expect(element.innerHTML).toBe('Loading...
');
241 |
242 | triggerHtmxEvent(element, 'htmx:responseError', {
243 | xhr: { status: 500, statusText: 'Error' }
244 | });
245 | await nextTick();
246 | expect(element.innerHTML).toBe('Error occurred
');
247 |
248 | // Wait for revert
249 | await new Promise(resolve => setTimeout(resolve, 250));
250 |
251 | // Should restore original complex content
252 | expect(element.innerHTML).toBe(originalHTML);
253 | });
254 | });
255 |
256 | describe('Error prevention', () => {
257 | it('should only show error once per request', async () => {
258 | element = createOptimisticElement(
259 | 'Click me ',
260 | {
261 | errorMessage: 'Error',
262 | errorMode: 'append',
263 | delay: 0
264 | }
265 | );
266 |
267 | triggerHtmxEvent(element, 'htmx:beforeRequest');
268 | await nextTick();
269 |
270 | // Trigger error multiple times
271 | triggerHtmxEvent(element, 'htmx:responseError', {
272 | xhr: { status: 500, statusText: 'Error' }
273 | });
274 | await nextTick();
275 |
276 | triggerHtmxEvent(element, 'htmx:responseError', {
277 | xhr: { status: 500, statusText: 'Error' }
278 | });
279 | await nextTick();
280 |
281 | // Should only have one error message
282 | const errorMessages = element.querySelectorAll('.hx-optimistic-error-message');
283 | expect(errorMessages.length).toBe(1);
284 | });
285 | });
286 | });
--------------------------------------------------------------------------------
/tests/integration/lifecycle-context.test.js:
--------------------------------------------------------------------------------
1 | import { describe, it, expect, vi } from 'vitest';
2 | import { createOptimisticElement, triggerHtmxEvent, nextTick, createTemplate } from '../helpers/test-utils.js';
3 |
4 | describe('Lifecycle and Context', () => {
5 | it('emits lifecycle events', async () => {
6 | const el = createOptimisticElement(
7 | 'X ',
8 | { values: { textContent: 'Loading...' }, errorMessage: 'E', delay: 10 }
9 | );
10 | triggerHtmxEvent(el, 'htmx:beforeRequest');
11 | await nextTick();
12 | expect(global.htmx.trigger).toHaveBeenCalledWith(el, 'optimistic:applied', expect.objectContaining({ config: expect.any(Object) }));
13 | triggerHtmxEvent(el, 'htmx:responseError', { xhr: { status: 500, statusText: 'Server Error' }, elt: el });
14 | await nextTick();
15 | expect(global.htmx.trigger).toHaveBeenCalledWith(el, 'optimistic:error', expect.objectContaining({ config: expect.any(Object), detail: expect.any(Object) }));
16 | await new Promise(r => setTimeout(r, 30));
17 | expect(global.htmx.trigger).toHaveBeenCalledWith(el, 'optimistic:reverted', expect.objectContaining({ config: expect.any(Object) }));
18 | });
19 |
20 | it('supports context in templates and error templates', async () => {
21 | const t1 = createTemplate('ctx-tpl', '${username}
');
22 | const t2 = createTemplate('err-tpl', '${username}-${status}
');
23 | const el = createOptimisticElement(
24 | 'X ',
25 | { template: '#ctx-tpl', context: { username: 'Alice' }, errorTemplate: '#err-tpl', delay: 10 }
26 | );
27 | triggerHtmxEvent(el, 'htmx:beforeRequest');
28 | await nextTick();
29 | expect(el.innerHTML).toContain('Alice');
30 | triggerHtmxEvent(el, 'htmx:responseError', { xhr: { status: 404, statusText: 'Not Found' }, elt: el });
31 | await nextTick();
32 | expect(el.innerHTML).toContain('Alice-404');
33 | t1.remove();
34 | t2.remove();
35 | });
36 |
37 | it('warns when template selectors do not resolve', async () => {
38 | const spy = vi.spyOn(console, 'warn').mockImplementation(() => {});
39 | const el = createOptimisticElement(
40 | 'X ',
41 | { template: '#missing', errorTemplate: '#missing-err', delay: 0, errorMessage: '' }
42 | );
43 | triggerHtmxEvent(el, 'htmx:beforeRequest');
44 | await nextTick();
45 | triggerHtmxEvent(el, 'htmx:responseError', { xhr: { status: 500, statusText: 'E' }, elt: el });
46 | await nextTick();
47 | expect(spy).toHaveBeenCalled();
48 | spy.mockRestore();
49 | });
50 |
51 | it('restores attributes and dataset, and can reprocess on revert', async () => {
52 | const spy = vi.spyOn(global.htmx, 'process');
53 | const el = createOptimisticElement(
54 | 'Y
',
55 | { values: { className: 'changed', 'data-foo': 'baz' }, errorMessage: 'E', delay: 10, reprocessOnRevert: true }
56 | );
57 | triggerHtmxEvent(el, 'htmx:beforeRequest');
58 | await nextTick();
59 | expect(el.dataset.foo).toBe('baz');
60 | triggerHtmxEvent(el, 'htmx:responseError', { xhr: { status: 500, statusText: 'E' }, elt: el });
61 | await new Promise(r => setTimeout(r, 30));
62 | expect(el.dataset.foo).toBe('bar');
63 | expect(el.getAttribute('title')).toBe('t');
64 | expect(spy).toHaveBeenCalled();
65 | spy.mockRestore();
66 | });
67 |
68 | it('supports swap modes beforeend and afterbegin', async () => {
69 | const el = createOptimisticElement(
70 | 'A
',
71 | { template: 'B ', swap: 'beforeend' }
72 | );
73 | triggerHtmxEvent(el, 'htmx:beforeRequest');
74 | await nextTick();
75 | expect(el.innerHTML.endsWith('B ')).toBe(true);
76 |
77 | const el2 = createOptimisticElement(
78 | 'A
',
79 | { template: 'B ', swap: 'afterbegin' }
80 | );
81 | triggerHtmxEvent(el2, 'htmx:beforeRequest');
82 | await nextTick();
83 | expect(el2.innerHTML.startsWith('B ')).toBe(true);
84 | });
85 |
86 | it('removes custom optimistic class on success', async () => {
87 | const el = createOptimisticElement(
88 | 'X ',
89 | { values: { textContent: 'Loading...' }, class: 'custom-opt' }
90 | );
91 | triggerHtmxEvent(el, 'htmx:beforeRequest');
92 | await nextTick();
93 | expect(el.classList.contains('custom-opt')).toBe(true);
94 | triggerHtmxEvent(el, 'htmx:afterSwap');
95 | await nextTick();
96 | expect(el.classList.contains('custom-opt')).toBe(false);
97 | });
98 | });
99 |
100 |
101 |
--------------------------------------------------------------------------------
/tests/unit/interpolation.test.js:
--------------------------------------------------------------------------------
1 | import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2 | import { createOptimisticElement, createTestForm, triggerHtmxEvent, nextTick, verifyWeakMapUsage } from '../helpers/test-utils.js';
3 |
4 | describe('Interpolation', () => {
5 | let element;
6 | let consoleWarnSpy;
7 |
8 | beforeEach(() => {
9 | // Spy on console.warn to test developer warnings
10 | consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
11 | });
12 |
13 | afterEach(() => {
14 | consoleWarnSpy.mockRestore();
15 | });
16 |
17 | describe('Basic interpolation patterns', () => {
18 | it('should interpolate ${this.value} for input elements', async () => {
19 | element = createOptimisticElement(
20 | ' ',
21 | {
22 | values: {
23 | textContent: 'Saving: ${this.value}'
24 | }
25 | }
26 | );
27 |
28 | // Trigger optimistic update through HTMX event
29 | triggerHtmxEvent(element, 'htmx:beforeRequest');
30 | await nextTick();
31 |
32 | expect(element.textContent).toBe('Saving: test value');
33 | });
34 |
35 | it('should interpolate ${this.textContent}', async () => {
36 | element = createOptimisticElement(
37 | 'Original Text
',
38 | {
39 | values: {
40 | title: 'Was: ${this.textContent}'
41 | }
42 | }
43 | );
44 |
45 | triggerHtmxEvent(element, 'htmx:beforeRequest');
46 | await nextTick();
47 |
48 | expect(element.title).toBe('Was: Original Text');
49 | });
50 |
51 | it('should interpolate ${this.dataset.key} for data attributes', async () => {
52 | element = createOptimisticElement(
53 | 'Click ',
54 | {
55 | values: {
56 | textContent: 'Count: ${this.dataset.count}'
57 | }
58 | }
59 | );
60 |
61 | triggerHtmxEvent(element, 'htmx:beforeRequest');
62 | await nextTick();
63 |
64 | expect(element.textContent).toBe('Count: 42');
65 | });
66 | });
67 |
68 | describe('New interpolation helpers', () => {
69 | it('should support ${data:key} shorthand for data attributes', async () => {
70 | element = createOptimisticElement(
71 | 'Click ',
72 | {
73 | values: {
74 | textContent: 'User ${data:user-name} (ID: ${data:user-id})'
75 | }
76 | }
77 | );
78 |
79 | triggerHtmxEvent(element, 'htmx:beforeRequest');
80 | await nextTick();
81 |
82 | expect(element.textContent).toBe('User John (ID: 123)');
83 | });
84 |
85 | it('should support ${attr:name} for any attribute', async () => {
86 | element = createOptimisticElement(
87 | 'Link ',
88 | {
89 | values: {
90 | textContent: 'Link to ${attr:href} - ${attr:title}'
91 | }
92 | }
93 | );
94 |
95 | triggerHtmxEvent(element, 'htmx:beforeRequest');
96 | await nextTick();
97 |
98 | expect(element.textContent).toBe('Link to /page - My Page');
99 | });
100 |
101 | it('should handle missing attributes gracefully', async () => {
102 | element = createOptimisticElement(
103 | 'Content
',
104 | {
105 | values: {
106 | textContent: 'Missing: ${data:nonexistent} and ${attr:missing}'
107 | }
108 | }
109 | );
110 |
111 | triggerHtmxEvent(element, 'htmx:beforeRequest');
112 | await nextTick();
113 |
114 | // Should keep the original pattern when attribute is missing
115 | expect(element.textContent).toBe('Missing: ${data:nonexistent} and ${attr:missing}');
116 | });
117 | });
118 |
119 | describe('Form field interpolation', () => {
120 | let form;
121 |
122 | afterEach(() => {
123 | if (form && form.parentNode) {
124 | form.parentNode.removeChild(form);
125 | }
126 | });
127 |
128 | it('should interpolate ${textarea} from form', async () => {
129 | form = createTestForm({
130 | comment: 'This is my comment',
131 | optimisticConfig: {
132 | values: {
133 | textContent: 'Posting: ${textarea}'
134 | }
135 | }
136 | });
137 |
138 | triggerHtmxEvent(form, 'htmx:beforeRequest');
139 | await nextTick();
140 |
141 | expect(form.textContent).toContain('Posting: This is my comment');
142 | });
143 |
144 | it('should interpolate ${email} from form', async () => {
145 | form = createTestForm({
146 | email: 'user@example.com',
147 | optimisticConfig: {
148 | values: {
149 | textContent: 'Email: ${email}'
150 | }
151 | }
152 | });
153 |
154 | triggerHtmxEvent(form, 'htmx:beforeRequest');
155 | await nextTick();
156 |
157 | expect(form.textContent).toContain('Email: user@example.com');
158 | });
159 |
160 | it('should interpolate ${password} from form', async () => {
161 | form = createTestForm({
162 | password: 'secret123',
163 | optimisticConfig: {
164 | values: {
165 | textContent: 'Password: ${password}'
166 | }
167 | }
168 | });
169 |
170 | triggerHtmxEvent(form, 'htmx:beforeRequest');
171 | await nextTick();
172 |
173 | expect(form.textContent).toContain('Password: secret123');
174 | });
175 |
176 | it('should interpolate ${url} from form', async () => {
177 | form = createTestForm({
178 | website: 'https://mysite.com',
179 | optimisticConfig: {
180 | values: {
181 | textContent: 'Website: ${url}'
182 | }
183 | }
184 | });
185 |
186 | triggerHtmxEvent(form, 'htmx:beforeRequest');
187 | await nextTick();
188 |
189 | expect(form.textContent).toContain('Website: https://mysite.com');
190 | });
191 |
192 | it('should interpolate ${tel} from form', async () => {
193 | form = createTestForm({
194 | phone: '555-0123',
195 | optimisticConfig: {
196 | values: {
197 | textContent: 'Phone: ${tel}'
198 | }
199 | }
200 | });
201 |
202 | triggerHtmxEvent(form, 'htmx:beforeRequest');
203 | await nextTick();
204 |
205 | expect(form.textContent).toContain('Phone: 555-0123');
206 | });
207 |
208 | it('should interpolate ${search} from form', async () => {
209 | form = createTestForm({
210 | query: 'my search query',
211 | optimisticConfig: {
212 | values: {
213 | textContent: 'Search: ${search}'
214 | }
215 | }
216 | });
217 |
218 | triggerHtmxEvent(form, 'htmx:beforeRequest');
219 | await nextTick();
220 |
221 | expect(form.textContent).toContain('Search: my search query');
222 | });
223 |
224 | it('should handle multiple form field interpolations', async () => {
225 | form = createTestForm({
226 | username: 'john',
227 | email: 'john@example.com',
228 | comment: 'Hello world',
229 | optimisticConfig: {
230 | values: {
231 | textContent: 'User ${text} (${email}): ${textarea}'
232 | }
233 | }
234 | });
235 |
236 | triggerHtmxEvent(form, 'htmx:beforeRequest');
237 | await nextTick();
238 |
239 | expect(form.textContent).toContain('User john (john@example.com): Hello world');
240 | });
241 |
242 | it('should handle form fields by name', async () => {
243 | form = createTestForm({
244 | username: 'testuser',
245 | optimisticConfig: {
246 | values: {
247 | textContent: 'User: ${username}'
248 | }
249 | }
250 | });
251 |
252 | triggerHtmxEvent(form, 'htmx:beforeRequest');
253 | await nextTick();
254 |
255 | expect(form.textContent).toContain('User: testuser');
256 | });
257 | });
258 |
259 | describe('Developer warnings', () => {
260 | it('should warn about unsupported querySelector patterns', async () => {
261 | element = createOptimisticElement(
262 | 'Content
',
263 | {
264 | values: {
265 | textContent: 'Result: ${this.querySelector(".test")}'
266 | }
267 | }
268 | );
269 |
270 | triggerHtmxEvent(element, 'htmx:beforeRequest');
271 | await nextTick();
272 |
273 | expect(consoleWarnSpy).toHaveBeenCalled();
274 | expect(consoleWarnSpy.mock.calls[0][0]).toContain('Unresolved interpolation pattern');
275 | });
276 |
277 | it('should warn about unsupported window references', async () => {
278 | element = createOptimisticElement(
279 | 'Content
',
280 | {
281 | values: {
282 | textContent: 'URL: ${window.location.href}'
283 | }
284 | }
285 | );
286 |
287 | triggerHtmxEvent(element, 'htmx:beforeRequest');
288 | await nextTick();
289 |
290 | expect(consoleWarnSpy).toHaveBeenCalled();
291 | expect(consoleWarnSpy.mock.calls[0][0]).toContain('Unresolved interpolation pattern');
292 | });
293 |
294 | it('should not warn about supported patterns', async () => {
295 | element = createOptimisticElement(
296 | 'Click ',
297 | {
298 | values: {
299 | textContent: 'Valid: ${data:test} and ${this.textContent}'
300 | }
301 | }
302 | );
303 |
304 | triggerHtmxEvent(element, 'htmx:beforeRequest');
305 | await nextTick();
306 |
307 | expect(consoleWarnSpy).not.toHaveBeenCalled();
308 | });
309 |
310 | it('should warn about Math expressions since arithmetic was removed', async () => {
311 | element = createOptimisticElement(
312 | 'Click ',
313 | {
314 | values: {
315 | textContent: 'Result: ${Math.max(0, count)}'
316 | }
317 | }
318 | );
319 |
320 | triggerHtmxEvent(element, 'htmx:beforeRequest');
321 | await nextTick();
322 |
323 | expect(consoleWarnSpy).toHaveBeenCalled();
324 | expect(consoleWarnSpy.mock.calls[0][0]).toContain('Unresolved interpolation pattern');
325 | });
326 | });
327 |
328 | describe('WeakMap usage verification', () => {
329 | it('should not store data in element dataset', async () => {
330 | element = createOptimisticElement(
331 | 'Click ',
332 | {
333 | values: {
334 | textContent: 'Loading...'
335 | }
336 | }
337 | );
338 |
339 | triggerHtmxEvent(element, 'htmx:beforeRequest');
340 | await nextTick();
341 |
342 | const verification = verifyWeakMapUsage(element);
343 | expect(verification.hasNoWeakMapLeaks).toBe(true);
344 | expect(verification.leakedKeys).toEqual([]);
345 | });
346 | });
347 |
348 | describe('Error template interpolation', () => {
349 | it('should interpolate error variables in error templates', async () => {
350 | element = createOptimisticElement(
351 | 'Click ',
352 | {
353 | errorTemplate: 'Error ${status}: ${statusText} - ${error}'
354 | }
355 | );
356 |
357 | triggerHtmxEvent(element, 'htmx:beforeRequest');
358 | await nextTick();
359 |
360 | triggerHtmxEvent(element, 'htmx:responseError', {
361 | xhr: {
362 | status: 404,
363 | statusText: 'Not Found'
364 | },
365 | error: 'Resource not found'
366 | });
367 | await nextTick();
368 |
369 | expect(element.innerHTML).toBe('Error 404: Not Found - Resource not found');
370 | });
371 |
372 | it('should handle missing error data gracefully', async () => {
373 | element = createOptimisticElement(
374 | 'Click ',
375 | {
376 | errorTemplate: 'Error ${status}: ${statusText} - ${error}'
377 | }
378 | );
379 |
380 | triggerHtmxEvent(element, 'htmx:beforeRequest');
381 | await nextTick();
382 |
383 | triggerHtmxEvent(element, 'htmx:responseError', {});
384 | await nextTick();
385 |
386 | expect(element.innerHTML).toBe('Error 0: Network Error - Request failed');
387 | });
388 |
389 | it('should interpolate error variables with source element data', async () => {
390 | element = createOptimisticElement(
391 | 'Click ',
392 | {
393 | errorTemplate: 'Error ${status} in ${data:context}: ${error}'
394 | }
395 | );
396 |
397 | triggerHtmxEvent(element, 'htmx:beforeRequest');
398 | await nextTick();
399 |
400 | triggerHtmxEvent(element, 'htmx:responseError', {
401 | xhr: {
402 | status: 403,
403 | statusText: 'Forbidden'
404 | },
405 | error: 'Access denied'
406 | });
407 | await nextTick();
408 |
409 | expect(element.innerHTML).toBe('Error 403 in user-profile: Access denied');
410 | });
411 | });
412 | });
--------------------------------------------------------------------------------
/tests/unit/target-resolution.test.js:
--------------------------------------------------------------------------------
1 | import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2 | import { createTargetTestElement, triggerHtmxEvent, nextTick, getExtension, verifyWeakMapUsage } from '../helpers/test-utils.js';
3 |
4 | describe('Target Resolution with sourceTargets WeakMap', () => {
5 | let testSetup;
6 | let extension;
7 |
8 | beforeEach(() => {
9 | extension = getExtension();
10 | expect(extension).toBeDefined();
11 | });
12 |
13 | afterEach(() => {
14 | if (testSetup && testSetup.container && testSetup.container.parentNode) {
15 | testSetup.container.parentNode.removeChild(testSetup.container);
16 | }
17 | });
18 |
19 | describe('Basic target resolution', () => {
20 | it('should resolve target to self when no hx-target specified', async () => {
21 | testSetup = createTargetTestElement({
22 | text: 'Click me',
23 | optimisticConfig: {
24 | values: {
25 | textContent: 'Loading...'
26 | }
27 | }
28 | });
29 |
30 | triggerHtmxEvent(testSetup.source, 'htmx:beforeRequest');
31 | await nextTick();
32 |
33 | expect(testSetup.source.textContent).toBe('Loading...');
34 | expect(testSetup.source.classList.contains('hx-optimistic')).toBe(true);
35 | });
36 |
37 | it('should resolve target using hx-target selector', async () => {
38 | testSetup = createTargetTestElement({
39 | text: 'Click me',
40 | hxTarget: '#test-target',
41 | optimisticConfig: {
42 | values: {
43 | textContent: 'Loading...'
44 | }
45 | }
46 | }, {
47 | id: 'test-target',
48 | text: 'Target content'
49 | });
50 |
51 | triggerHtmxEvent(testSetup.source, 'htmx:beforeRequest');
52 | await nextTick();
53 |
54 | expect(testSetup.target.textContent).toBe('Loading...');
55 | expect(testSetup.target.classList.contains('hx-optimistic')).toBe(true);
56 | expect(testSetup.source.textContent).toBe('Click me'); // Source unchanged
57 | });
58 | });
59 |
60 | describe('Closest selector resolution', () => {
61 | it('should resolve "closest" selector correctly', async () => {
62 | testSetup = createTargetTestElement({
63 | text: 'Click me',
64 | hxTarget: 'closest .parent',
65 | optimisticConfig: {
66 | values: {
67 | className: 'parent updated'
68 | }
69 | }
70 | }, {
71 | className: 'target',
72 | parent: 'parent',
73 | text: 'Target content'
74 | });
75 |
76 | triggerHtmxEvent(testSetup.source, 'htmx:beforeRequest');
77 | await nextTick();
78 |
79 | const parent = testSetup.container.querySelector('.parent');
80 | expect(parent.className).toBe('parent updated hx-optimistic');
81 | });
82 | });
83 |
84 | describe('Find selector resolution', () => {
85 | it('should resolve "find" selector correctly', async () => {
86 | testSetup = createTargetTestElement({
87 | text: 'Click me',
88 | hxTarget: 'find .target',
89 | optimisticConfig: {
90 | values: {
91 | textContent: 'Found and updated'
92 | }
93 | }
94 | }, {
95 | className: 'target',
96 | text: 'Original target'
97 | });
98 |
99 | triggerHtmxEvent(testSetup.source, 'htmx:beforeRequest');
100 | await nextTick();
101 |
102 | expect(testSetup.target.textContent).toBe('Found and updated');
103 | expect(testSetup.target.classList.contains('hx-optimistic')).toBe(true);
104 | });
105 | });
106 |
107 | describe('Next selector resolution', () => {
108 | it('should resolve "next" selector correctly', async () => {
109 | const container = document.createElement('div');
110 | const source = document.createElement('button');
111 | source.textContent = 'Click me';
112 | source.setAttribute('hx-post', '/api/test');
113 | source.setAttribute('hx-ext', 'optimistic');
114 | source.setAttribute('hx-target', 'next .target');
115 | source.setAttribute('data-optimistic', JSON.stringify({
116 | values: {
117 | textContent: 'Next element updated'
118 | }
119 | }));
120 |
121 | const sibling1 = document.createElement('div');
122 | sibling1.className = 'other';
123 | sibling1.textContent = 'Not target';
124 |
125 | const target = document.createElement('div');
126 | target.className = 'target';
127 | target.textContent = 'Target element';
128 |
129 | container.appendChild(source);
130 | container.appendChild(sibling1);
131 | container.appendChild(target);
132 | document.body.appendChild(container);
133 |
134 | triggerHtmxEvent(source, 'htmx:beforeRequest');
135 | await nextTick();
136 |
137 | expect(target.textContent).toBe('Next element updated');
138 | expect(target.classList.contains('hx-optimistic')).toBe(true);
139 |
140 | // Cleanup
141 | container.parentNode.removeChild(container);
142 | });
143 | });
144 |
145 | describe('Previous selector resolution', () => {
146 | it('should resolve "previous" selector correctly', async () => {
147 | const container = document.createElement('div');
148 |
149 | const target = document.createElement('div');
150 | target.className = 'target';
151 | target.textContent = 'Target element';
152 |
153 | const sibling1 = document.createElement('div');
154 | sibling1.className = 'other';
155 | sibling1.textContent = 'Not target';
156 |
157 | const source = document.createElement('button');
158 | source.textContent = 'Click me';
159 | source.setAttribute('hx-post', '/api/test');
160 | source.setAttribute('hx-ext', 'optimistic');
161 | source.setAttribute('hx-target', 'previous .target');
162 | source.setAttribute('data-optimistic', JSON.stringify({
163 | values: {
164 | textContent: 'Previous element updated'
165 | }
166 | }));
167 |
168 | container.appendChild(target);
169 | container.appendChild(sibling1);
170 | container.appendChild(source);
171 | document.body.appendChild(container);
172 |
173 | triggerHtmxEvent(source, 'htmx:beforeRequest');
174 | await nextTick();
175 |
176 | expect(target.textContent).toBe('Previous element updated');
177 | expect(target.classList.contains('hx-optimistic')).toBe(true);
178 |
179 | // Cleanup
180 | container.parentNode.removeChild(container);
181 | });
182 | });
183 |
184 | describe('sourceTargets WeakMap functionality', () => {
185 | it('should store source-target mapping in WeakMap during error handling', async () => {
186 | testSetup = createTargetTestElement({
187 | text: 'Click me',
188 | hxTarget: '#test-target',
189 | optimisticConfig: {
190 | values: {
191 | textContent: 'Loading...'
192 | },
193 | errorMessage: 'Failed',
194 | delay: 100
195 | }
196 | }, {
197 | id: 'test-target',
198 | text: 'Target content'
199 | });
200 |
201 | // Trigger optimistic update
202 | triggerHtmxEvent(testSetup.source, 'htmx:beforeRequest');
203 | await nextTick();
204 |
205 | expect(testSetup.target.textContent).toBe('Loading...');
206 |
207 | // Trigger error - should use sourceTargets WeakMap to find the target
208 | triggerHtmxEvent(testSetup.source, 'htmx:responseError', {
209 | xhr: { status: 500, statusText: 'Server Error' }
210 | });
211 | await nextTick();
212 |
213 | expect(testSetup.target.textContent).toBe('Failed');
214 | expect(testSetup.target.classList.contains('hx-optimistic-error')).toBe(true);
215 |
216 | // Verify no data leaked to dataset
217 | const verification = verifyWeakMapUsage(testSetup.source);
218 | expect(verification.hasNoWeakMapLeaks).toBe(true);
219 | });
220 |
221 | it('should handle error when source element has no stored target', async () => {
222 | const orphanSource = document.createElement('button');
223 | orphanSource.textContent = 'Orphan button';
224 | document.body.appendChild(orphanSource);
225 |
226 | // Trigger error on element that was never processed
227 | triggerHtmxEvent(orphanSource, 'htmx:responseError', {
228 | xhr: { status: 500, statusText: 'Server Error' }
229 | });
230 | await nextTick();
231 |
232 | // Should not throw error and should gracefully handle missing target
233 | expect(orphanSource.textContent).toBe('Orphan button');
234 |
235 | // Cleanup
236 | orphanSource.parentNode.removeChild(orphanSource);
237 | });
238 | });
239 |
240 | describe('Complex target scenarios', () => {
241 | it('should handle nested target resolution', async () => {
242 | const container = document.createElement('div');
243 | container.innerHTML = `
244 |
245 |
246 |
250 | Click me
251 |
252 |
253 |
Original deep content
254 |
255 |
256 |
257 | `;
258 | document.body.appendChild(container);
259 |
260 | const button = container.querySelector('button');
261 | const deepTarget = container.querySelector('.deep-target');
262 |
263 | triggerHtmxEvent(button, 'htmx:beforeRequest');
264 | await nextTick();
265 |
266 | expect(deepTarget.textContent).toBe('Deep target updated');
267 | expect(deepTarget.classList.contains('hx-optimistic')).toBe(true);
268 |
269 | // Cleanup
270 | container.parentNode.removeChild(container);
271 | });
272 |
273 | it('should handle target not found gracefully', async () => {
274 | testSetup = createTargetTestElement({
275 | text: 'Click me',
276 | hxTarget: '#nonexistent-target',
277 | optimisticConfig: {
278 | values: {
279 | textContent: 'Loading...'
280 | }
281 | }
282 | });
283 |
284 | const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
285 |
286 | triggerHtmxEvent(testSetup.source, 'htmx:beforeRequest');
287 | await nextTick();
288 |
289 | // Should warn about target not found
290 | expect(consoleSpy).toHaveBeenCalledWith(
291 | '[hx-optimistic] Target element not found for:',
292 | testSetup.source
293 | );
294 |
295 | // Source should remain unchanged
296 | expect(testSetup.source.textContent).toBe('Click me');
297 | expect(testSetup.source.classList.contains('hx-optimistic')).toBe(false);
298 |
299 | consoleSpy.mockRestore();
300 | });
301 | });
302 | });
--------------------------------------------------------------------------------
/vitest.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vitest/config';
2 |
3 | export default defineConfig({
4 | test: {
5 | globals: true,
6 | environment: 'jsdom',
7 | setupFiles: ['./tests/helpers/setup.js'],
8 | coverage: {
9 | provider: 'v8',
10 | reporter: ['text', 'json', 'html', 'lcov'],
11 | exclude: [
12 | 'node_modules/**',
13 | 'tests/**',
14 | 'demo/**',
15 | 'src/**',
16 | '*.config.js',
17 | 'hx-optimistic.min.js'
18 | ],
19 | thresholds: {
20 | branches: 74,
21 | functions: 90,
22 | lines: 90,
23 | statements: 90
24 | }
25 | },
26 | testTimeout: 10000,
27 | hookTimeout: 10000
28 | }
29 | });
--------------------------------------------------------------------------------