Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 106 additions & 1 deletion vibetuner-docs/docs/htmx-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,8 @@ the changes specific to beta3 (which is the 4.0 release candidate).
proxy, sigil-syntax `toggle('@attr')` / `toggle('*display=none|block')`,
per-element `debounce(ms[, fn])`, and `htmx.live.q` /
`htmx.live.take(target, className, source)` outside expression scope.
**Default-on in vibetuner** as of `@alltuner/vibetuner` 10.15.0 — see
[Live Reactivity](#live-reactivity-with-hx-live) below.

### New Swap Style: `outerSync`

Expand Down Expand Up @@ -607,7 +609,110 @@ for history snapshots, the same as in htmx 2.
the new `hx-live` extension and are exposed via the `htmx.live`
namespace (e.g. `htmx.live.take(target, className, source)`). If you
were calling them directly, import the `hx-live` extension or migrate
to native equivalents.
to native equivalents. Vibetuner loads `hx-live` by default, so
`htmx.live.take(...)` is available without extra setup.

## Live Reactivity with `hx-live`

`@alltuner/vibetuner` 10.15.0 imports `hx-live` by default from
`config.js` (in the framework-managed block, alongside `hx-preload`).
This is vibetuner's recommended path for client-side reactivity —
chip lists, derived form fields, live filters, paired controls.
Reach for it before adding Alpine.js, Stimulus, or hand-rolled
event-listener boilerplate.

### Why it fits vibetuner

Vibetuner's CSP runs `script-src 'nonce-X' 'strict-dynamic'` with
no `'unsafe-inline'`, which blocks inline `onclick="..."` /
`onchange="..."` attributes at the spec level. htmx attributes
(`hx-on:`, `hx-live`) evaluate through htmx's nonced TrustedTypes
pipeline, so they work where raw handler attributes do not. If you
also enable `hx-nonce`, every `hx-live` expression gets the same
defence-in-depth as `hx-get` / `hx-post`.

### Idiomatic patterns

**Derive a hidden field from a chip list** (the OAuth scope editor
in `debug/oauth_app_form.html.jinja` uses this exact shape):

```jinja
<div id="scopes-tags"
hx-on:click="
const btn = event.target.closest('button[data-action=remove-scope]');
if (btn) btn.closest('.badge').remove();
">
{% for scope in scopes %}
<span class="badge" data-scope="{{ scope }}">
<span data-text>{{ scope }}</span>
<button type="button" data-action="remove-scope">×</button>
</span>
{% endfor %}
</div>
<input type="hidden" name="scopes"
hx-live="this.value = q('#scopes-tags .badge').arr()
.map(b => b.dataset.scope).join(',')">
```

The hidden input recomputes its `value` on every mutation under
`#scopes-tags` — add and remove both stay in sync without manual
wiring. Event delegation on the container handles remove clicks
on both initially-rendered and dynamically-inserted badges
(`q().insert()` is raw `insertAdjacentHTML` — htmx does not
re-process inserted nodes).

**Conditional CSS class from a sibling input:**

```jinja
<input id="age" type="number" value="0">
<p hx-live="this.classList.toggle('text-error',
q('#age').valueAsNumber < 18)">
Adult content
</p>
```

**Debounced live search:**

```jinja
<input id="q" placeholder="search">
<output hx-live="
let term = q('#q').value;
if (!term) { this.textContent = ''; return; }
await debounce(250);
this.textContent = await fetch('/search?q=' +
encodeURIComponent(term)).then(r => r.text());
"></output>
```

The `await debounce(250)` is per-element — successive keystrokes
cancel the in-flight call via async rejection, so only the final
term hits the server.

### Rough edges to know

- **The DOM is the only source of truth.** No JS-variable reactivity.
Share state via `data-*` attributes or hidden inputs. Alpine.js
refugees will expect refs — `hx-live` deliberately doesn't have them.
- **Expressions re-run on *any* DOM mutation.** Cheap by default, but
unconditional side effects (a bare `fetch()`, mutating the DOM tree
the expression reads) will tank performance or trip the >50/s
self-mutation cutoff (the expression deactivates with a console
warning). Guard with `debounce` or a value-change check.
- **Set-property writes broadcast silently.** `q('.field').value = ''`
writes to every matching element. A selector that accidentally
widens (e.g. an `:inherited` attribute unexpectedly inheriting)
clobbers things you didn't intend. Prefer narrow selectors and
add `data-*` markers where ambiguity is possible.
- **`next` / `prev` / `closest` anchor to `this`.** Inside `hx-live`
they mean "relative to the owner element". Calling
`htmx.live.q('next .foo')` from a free-floating script is undefined.
- **`q().insert(pos, html)` does not run `htmx.process()`** on the
inserted markup. Dynamic `hx-on:` / `hx-live` attributes won't be
wired. Use event delegation on a stable parent (as in the chip-list
pattern above) or call `htmx.process(elt)` after insertion.
- **`hx-config` no longer accepts request `mode` overrides** in beta3
(security fix). Unrelated to `hx-live` itself but ships together —
drop `hx-config="mode:..."` attributes if you have them.

## Common Migration Issues

Expand Down
98 changes: 95 additions & 3 deletions vibetuner-docs/docs/llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -1837,6 +1837,96 @@ file only ships with `htmx.org@4.0.0-beta3`, pulled transitively by
`@alltuner/vibetuner` 10.11.0+; on older versions the bundler errors
with `Could not resolve "./node_modules/htmx.org/dist/ext/hx-nonce.js"`.

**htmx Live Reactivity (default-on, requires `@alltuner/vibetuner` ≥ 10.15.0):**
htmx 4.0.0-beta3's `hx-live` extension is loaded by default from the
framework-managed block of `config.js`, alongside `hx-preload`. This is
vibetuner's recommended path for client-side reactivity — chip lists,
derived form fields, live filters, paired controls, inline validation.
Reach for it before adding Alpine.js, Stimulus, or hand-rolled
event-listener boilerplate.

**Why it fits the stack.** Vibetuner's `script-src 'nonce-X' 'strict-dynamic'`
CSP blocks inline `onclick="..."` / `onchange="..."` attributes at the spec
level. `hx-on:` and `hx-live` evaluate through htmx's nonced TrustedTypes
pipeline, so they work where raw handler attributes do not. Pair with the
opt-in `hx-nonce` extension for defence-in-depth.

**Primitives** (full reference at <https://four.htmx.org/extensions/hx-live/>):

- `hx-live="<expr>"` — JS expression re-run on any `input`/`change`/mutation,
with `q()`, `wait`, `trigger`, `debounce`, and the htmx public API in scope.
Async function body (top-level `await` allowed).
- `q(selector)` — jQuery-like proxy. Selector grammar: `next` / `prev` /
`closest`, `'.foo in #scope'`, `'.foo in this'`, chained `.q(...)`. Reads
return the first match; assigns write to all matches. `q('.x').arr()`
for a real `Array<Element>`.
- Built-in shortcuts: `.trigger(name, detail)`, `.insert(pos, html)` (raw
`insertAdjacentHTML` — does not run `htmx.process()`), `.take(class, from)`
(move class from peers).
- Sigil `toggle('@attr')` / `toggle('*display=none|block')` for attribute /
style flips.
- `debounce(ms)` — per-element, cancels prior in-flight via async rejection.
- `htmx.live.*` — same primitives outside expression scope (e.g.
`htmx.live.take(target, className, source)` for the migrated
`htmx.takeClass()`).

**Pattern: chip-list with derived hidden input** (the OAuth scope editor in
`debug/oauth_app_form.html.jinja` is the canonical in-tree example):

```jinja
<div id="scopes-tags"
hx-on:click="
const btn = event.target.closest('button[data-action=remove-scope]');
if (btn) btn.closest('.badge').remove();
">
{% for scope in scopes %}
<span class="badge" data-scope="{{ scope }}">
<span data-text>{{ scope }}</span>
<button type="button" data-action="remove-scope">×</button>
</span>
{% endfor %}
</div>
<input type="hidden" name="scopes"
hx-live="this.value = q('#scopes-tags .badge').arr()
.map(b => b.dataset.scope).join(',')">
```

The hidden input recomputes on every mutation under `#scopes-tags` — add
and remove paths stay in sync without manual wiring. Event delegation on
the container handles both initially-rendered and dynamically-inserted
badges (`q().insert()` does not re-process inserted nodes).

**Pattern: debounced live search:**

```jinja
<output hx-live="
let term = q('#q').value;
if (!term) { this.textContent = ''; return; }
await debounce(250);
this.textContent = await fetch('/search?q=' +
encodeURIComponent(term)).then(r => r.text());
"></output>
```

**Rough edges:**

- **The DOM is the only source of truth.** No JS-variable reactivity; share
state via `data-*` attributes or hidden inputs.
- **Expressions re-run on every DOM mutation.** Idempotent or
cheap-or-guarded only. The runtime deactivates expressions that mutate
more than 50 times per second.
- **Set-property writes broadcast silently** to all matched elements.
Narrow your selectors.
- **`next` / `prev` / `closest` anchor to `this`.** Undefined when called
from `htmx.live.q(...)` outside an expression.
- **`q().insert()` is raw `insertAdjacentHTML`.** Dynamic `hx-on:` /
`hx-live` attributes on inserted markup won't be wired. Use event
delegation on a stable parent or call `htmx.process(elt)` after insertion.
- **Breaking from beta2:** `htmx.takeClass()` / `htmx.forEvent()` moved
out of core. Use `htmx.live.take(...)` / `htmx.live.forEvent(...)`.
Framework code has no usages, so this is a non-event for users on the
default scaffolding; user code calling those globals will need an update.

### Background Tasks

Run long-running or scheduled work outside the request cycle using Vibetuner's
Expand Down Expand Up @@ -2274,9 +2364,11 @@ relevant to vibetuner projects:
enable. Framework templates already follow the pattern. Elements
without a matching nonce are stripped (fail-closed)
- **`hx-live` extension** — DOM-reactivity via `hx-live="..."` plus a
richer `hx-on` JS surface (`q()`, `toggle()`, `debounce()`). Note:
`htmx.takeClass` and `htmx.forEvent` moved out of core into this
extension (now `htmx.live.take(...)` etc.)
richer `hx-on` JS surface (`q()`, `toggle()`, `debounce()`). **Default-on
in vibetuner** since `@alltuner/vibetuner` 10.15.0; see the Live
Reactivity section above for patterns. Note: `htmx.takeClass` and
`htmx.forEvent` moved out of core into this extension (now
`htmx.live.take(...)` etc.)
- **`hx-history-elt` restored** — was removed in earlier 4.x pre-releases,
brought back in beta3 alongside an improved `hx-history-cache`
extension
Expand Down
10 changes: 10 additions & 0 deletions vibetuner-docs/docs/llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,16 @@ Important notes:
enable. Elements without a matching `hx-nonce` are stripped (fail-closed). Requires
`@alltuner/vibetuner` ≥ 10.11.0 (older releases pull `htmx.org@4.0.0-beta2` which
does not ship the extension file)
- **htmx Live Reactivity (default-on)**: htmx 4.0.0-beta3's `hx-live` extension is
loaded by default from `config.js` since `@alltuner/vibetuner` 10.15.0. Use
`hx-live="<expr>"` for derived state that recomputes on any DOM mutation,
`hx-on:event="..."` with the `q()` proxy / sigil-`toggle()` / per-element
`debounce()` for richer event handlers. CSP-clean (evaluates through htmx's
nonced TrustedTypes path) where raw inline `onclick=` is not. Vibetuner's
preferred path for chip lists, paired controls, live filters, and inline
form-field validation — use this before reaching for Alpine.js or Stimulus.
See `vibetuner-docs/docs/htmx-migration.md#live-reactivity-with-hx-live` for
idiomatic patterns and rough edges
- **Health Checks**: `/health`, `/health/ping`, `/health/ready`, `/health/id` endpoints
with service connectivity checks
- **Robust Tasks**: `@robust_task()` decorator with exponential backoff retries and
Expand Down
3 changes: 3 additions & 0 deletions vibetuner-js/htmx-live.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// ABOUTME: Re-exports the htmx hx-live extension via @alltuner/vibetuner/htmx/live
// ABOUTME: so scaffolded projects don't need a direct htmx.org dep to enable it.
import "htmx.org/dist/ext/hx-live.js";
3 changes: 2 additions & 1 deletion vibetuner-js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@
"./core.css": "./core.css",
"./htmx": "./htmx.js",
"./htmx/preload": "./htmx-preload.js",
"./htmx/sse": "./htmx-sse.js"
"./htmx/sse": "./htmx-sse.js",
"./htmx/live": "./htmx-live.js"
},
"bin": {
"tailwindcss": "./bin/tailwindcss.js"
Expand Down
Loading