Skip to main content

max / makenotwork

Fold the private data-hx-* vocabulary into the dispatcher's verbs Two private vocabularies sat side by side: data-action, with a typed registry and positional args, and ten data-hx-* attributes with a dispatcher of their own that resolved nothing the same way. Two of the ten also collided with htmx's own data-hx-* alias, so at eight sites the same element was read twice, once as a private directive and once as a real htmx attribute htmx then acted on. That is the bug this closes. They are verbs now, in the one registry. The verb says what happens and the attribute says when: data-after after a successful htmx request, data-after-always regardless of outcome. Arguments come from the data-arg and data-target conventions the click family already used, so nothing about resolution is special to the hook. A value may name more than one verb; the two forms that reset a form and then refresh their panel say "reset refresh". Registered handlers take (args, evt) rather than spread arguments, which is what lets a verb branch on the request outcome without a second registry. The window.<verb> bridge still calls positionally, so the 113 globals the frontend_globals ratchet counts are untouched. One registered call site. fade-row-added is gone, split into label-added and fade-row. It relabelled unconditionally and faded only on success, which needed the outcome inside one verb; across two hooks it does not. It relabels without disabling, unlike mark-added, and that difference is preserved. 30 sites in 12 templates. No behaviour changes.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-10 14:19 UTC
Signed with PGP, not checked
Commit: e739a8965b550d0635ea233db12da71ad481024b
Parent: 0921b62
19 files changed, +146 insertions, -93 deletions
@@ -200,9 +200,16 @@
200 200 ## Interaction idioms, and the one that is refused
201 201
202 202 Two idioms carry every behaviour on this site. htmx moves markup over the wire. The
203 - delegated dispatcher in `frontend/src/core/dispatch.ts` names behaviour with `data-action`
204 - and friends, resolved against a typed registry. Both are installed once, at the document
205 - level, and neither puts logic in an attribute value.
203 + delegated dispatcher in `frontend/src/core/dispatch.ts` names behaviour with a verb,
204 + resolved against a typed registry. Both are installed once, at the document level, and
205 + neither puts logic in an attribute value.
206 +
207 + The dispatcher is one vocabulary over several hooks. The verb says what happens, the
208 + attribute says when: `data-action` on click, `data-change` / `data-input` / `data-submit`
209 + on the other three DOM events, `data-after` and `data-after-always` on htmx's request
210 + lifecycle. Arguments come from `data-arg` / `data-arg2`, and a verb acting on another
211 + element takes `data-target` (element ids) or `data-href`. Never invent a private attribute
212 + for a behaviour; add a verb.
206 213
207 214 **`_hyperscript` is declined, permanently.** It appears in zero files today, so this
208 215 records a closed question rather than a reversal. Four reasons, heaviest first:
@@ -764,7 +764,7 @@
764 764 };
765 765 // script-src is 'self' (+ Stripe) with NO 'unsafe-inline': all inline
766 766 // on*/hx-on handlers were moved to delegated listeners in static/*.js
767 - // (the data-action / data-hx-* dispatchers in mnw.js), so any injected
767 + // (the data-action / data-after dispatchers in frontend/src/core), so any injected
768 768 // markup can no longer execute script. style-src keeps 'unsafe-inline'
769 769 // because inline style="" attributes are still used throughout.
770 770 let policy = |style_src: &str| {
@@ -192,7 +192,7 @@
192 192 <td>
193 193 <button class="btn-primary small cart-row-btn"
194 194 hx-post="/api/cart/{{ item.item_id }}"
195 - data-hx-always="fade-row-added">Add to Cart</button>
195 + data-after-always="label-added" data-after="fade-row">Add to Cart</button>
196 196 </td>
197 197 </tr>
198 198 {% endfor %}
@@ -5,7 +5,7 @@
5 5 hx-post="/dashboard/onboarding/dismiss"
6 6 hx-target="#onboarding-area"
7 7 hx-swap="innerHTML"
8 - data-hx-toast="Checklist hidden. You can restore it from the dashboard." data-hx-toast-type="info"
8 + data-after="toast" data-arg="Checklist hidden. You can restore it from the dashboard." data-arg2="info"
9 9 class="btn-tiny">Hide for now</button>
10 10 </div>
11 11 <div class="progress-bar-container progress-bar-container--slim mb-section">
@@ -1,21 +1,38 @@
1 - // The action dispatcher + HTMX callback dispatcher, ported from mnw.js:724-871.
1 + // The delegated dispatcher, ported from mnw.js:724-871.
2 2 //
3 - // These let templates drop inline `on*` / `hx-on::` handlers so the CSP keeps
4 - // `script-src 'self'` (no 'unsafe-inline'). The one behavioral change from the
5 - // port: `data-action` verbs resolve against a typed `register()` registry
6 - // FIRST, falling back to a `window.<verb>` global for handlers not yet migrated
7 - // off the legacy `static/*.js` files. New code should `register()`, never add a
8 - // global, the `frontend_globals` seal enforces the ratchet.
3 + // It lets templates drop inline `on*` / `hx-on::` handlers so the CSP keeps
4 + // `script-src 'self'` (no 'unsafe-inline'). Verbs resolve against a typed
5 + // `register()` registry FIRST, falling back to a `window.<verb>` global for
6 + // handlers not yet migrated off the legacy `static/*.js` files. New code should
7 + // `register()`, never add a global, the `frontend_globals` seal enforces the
8 + // ratchet.
9 + //
10 + // ONE vocabulary, several hooks. A verb names what happens; the attribute names
11 + // when. `data-action` is a click, `data-change` / `data-input` / `data-submit`
12 + // are the other three DOM events, and `data-after` / `data-after-always` are
13 + // htmx's request lifecycle. Arguments come from `data-arg` / `data-arg2`, the
14 + // target of a verb that acts on another element from `data-target` or
15 + // `data-href`, whichever hook dispatched it. The same verb reached two ways is
16 + // the same registry entry.
9 17
10 18 import { byId, targets } from './dom.ts';
11 19 import { showToast } from './toast.ts';
12 20
13 - export type ActionHandler = (this: Element, ...args: string[]) => void;
21 + /** A named verb. `args` are the positional `data-arg` / `data-arg2` values.
22 + * `evt` is whatever triggered the dispatch: a DOM event for the click family,
23 + * htmx's `htmx:afterRequest` CustomEvent for the `data-after` family. Most
24 + * verbs ignore it; the ones that branch on the request outcome do not. */
25 + export type ActionHandler = (this: Element, args: string[], evt: Event) => void;
26 +
27 + /** The legacy `window.<verb>` bridge calls positionally, which is the shape
28 + * those handlers were written against and the reason they are still callable
29 + * without being ported. */
30 + type LegacyHandler = (this: Element, ...args: string[]) => void;
14 31
15 32 const registry = new Map<string, ActionHandler>();
16 33
17 - /** Register a named `data-action` handler. Preferred over a `window.<name>`
18 - * global; the dispatcher checks the registry before the global fallback. */
34 + /** Register a named verb. Preferred over a `window.<name>` global; the
35 + * dispatcher checks the registry before the global fallback. */
19 36 export function register(name: string, fn: ActionHandler): void {
20 37 registry.set(name, fn);
21 38 }
@@ -55,13 +72,30 @@
55 72 builtin(el);
56 73 return;
57 74 }
75 + const args = collectArgs(el);
58 76 // Registry first, then the legacy `window.<verb>` global (strangler bridge).
59 - const handler = registry.get(verb) ?? (window as unknown as Record<string, unknown>)[verb];
60 - if (typeof handler !== 'function') {
77 + const registered = registry.get(verb);
78 + if (registered) {
79 + registered.call(el, args, evt);
80 + return;
81 + }
82 + const global = (window as unknown as Record<string, unknown>)[verb];
83 + if (typeof global !== 'function') {
61 84 console.warn('data-action: no handler for', verb);
62 85 return;
63 86 }
64 - (handler as ActionHandler).apply(el, collectArgs(el));
87 + (global as LegacyHandler).apply(el, args);
88 + }
89 +
90 + /** Run every verb named in a space-separated attribute value, in written order.
91 + * One element can ask for more than one thing: a form that resets itself and
92 + * then refreshes the panel it lives in says `reset refresh`. */
93 + function runList(el: Element, attr: string, evt: Event): void {
94 + const value = el.getAttribute(attr);
95 + if (!value) return;
96 + for (const verb of value.split(/\s+/)) {
97 + if (verb) run(el, verb, evt);
98 + }
65 99 }
66 100
67 101 function listen(eventName: string, attr: string, autoPrevent: boolean): void {
@@ -102,7 +136,18 @@
102 136 listen('submit', 'data-submit', true);
103 137 }
104 138
105 - // ---- HTMX callback dispatcher (hx-on:: replacement), ported from mnw.js:799 ----
139 + // ---- The `data-after` family, dispatched on htmx's request lifecycle ----
140 + //
141 + // These were ten private `data-hx-*` attributes with their own dispatcher, a
142 + // second vocabulary sitting beside `data-action` and resolving nothing the same
143 + // way. Two of the ten (`data-hx-get`, `data-hx-target`) also collided with
144 + // htmx's own `data-hx-*` alias, so at eight sites htmx read the private
145 + // directive as a real htmx attribute and acted on it as well.
146 + //
147 + // They are verbs now, in the one registry, taking their arguments from the
148 + // `data-arg` / `data-target` / `data-href` conventions the click family already
149 + // uses. What is left is the hook they run on, which is what `data-after` and
150 + // `data-after-always` name.
106 151
107 152 interface HxDetail {
108 153 elt: HTMLElement;
@@ -120,61 +165,62 @@
120 165 }
121 166 }
122 167
123 - type BehaviorFn = (el: HTMLElement, evt: CustomEvent<HxDetail>) => void;
168 + /** Verbs the `data-after` family introduced. Registered rather than kept in a
169 + * map of their own, so `data-action="reload"` and `data-after="reload"` are the
170 + * same verb reached through different hooks. */
171 + function registerAfterVerbs(): void {
172 + register('reset', function () {
173 + const form = this as unknown as HTMLFormElement;
174 + if (typeof form.reset === 'function') form.reset();
175 + });
176 + register('reload', () => window.location.reload());
177 + register('refresh', (args) => {
178 + const [url, target] = args;
179 + if (url && target) htmx.ajax('GET', url, target);
180 + });
181 + register('toast', (args) => showToast(args[0] ?? '', args[1] || 'info'));
182 + register('label-added', function () {
183 + this.textContent = 'Added';
184 + });
185 + register('mark-added', function () {
186 + this.textContent = 'Added';
187 + (this as HTMLButtonElement).disabled = true;
188 + });
189 + // `label-added` + `fade-row` is the old `fade-row-added`, which relabelled
190 + // unconditionally and faded only on success. Two hooks say that between them;
191 + // one verb had to read the outcome to say it. Note it relabels without
192 + // disabling, which is what that button did and is not what `mark-added` does.
193 + register('fade-row', function () {
194 + this.closest('tr')?.classList.add('is-faded');
195 + });
196 + register('refresh-profile', () => refreshProfileTab());
197 + // The only verb that reads the response rather than the element. The verify
198 + // endpoint answers 200 whether or not the TXT record matched, so success
199 + // alone does not mean verified.
200 + register('refresh-profile-if-verified', (_args, evt) => {
201 + const xhr = (evt as CustomEvent<HxDetail>).detail?.xhr;
202 + if (xhr && xhr.responseText.indexOf('verified successfully') !== -1) refreshProfileTab();
203 + });
204 + }
124 205
125 - const BEHAVIORS: Record<string, BehaviorFn> = {
126 - 'mark-added': (el) => {
127 - el.textContent = 'Added';
128 - (el as HTMLButtonElement).disabled = true;
129 - },
130 - // Faithful to the inline original: the row fades only on success, but the
131 - // label flips to 'Added' unconditionally (registered under data-hx-always).
132 - 'fade-row-added': (el, evt) => {
133 - if (evt.detail.successful) el.closest('tr')?.classList.add('is-faded');
134 - el.textContent = 'Added';
135 - },
136 - 'refresh-profile': () => refreshProfileTab(),
137 - 'refresh-profile-if-verified': (_el, evt) => {
138 - if (evt.detail.xhr && evt.detail.xhr.responseText.indexOf('verified successfully') !== -1) {
139 - refreshProfileTab();
140 - }
141 - },
142 - };
206 + /** Install the `data-after` / `data-after-always` / `data-config` dispatcher. */
207 + export function initAfterRequestDispatcher(): void {
208 + registerAfterVerbs();
143 209
144 - /** Install the `data-hx-*` afterRequest/configRequest dispatcher. */
145 - export function initHxCallbackDispatcher(): void {
146 210 document.body.addEventListener('htmx:afterRequest', (e) => {
147 211 const evt = e as CustomEvent<HxDetail>;
148 212 const el = evt.detail.elt;
149 213 if (!el || !el.getAttribute) return;
150 214
151 - const always = el.getAttribute('data-hx-always');
152 - if (always && BEHAVIORS[always]) BEHAVIORS[always](el, evt);
153 - if (el.hasAttribute('data-hx-always-click')) byId(el.getAttribute('data-hx-always-click'))?.click();
154 -
215 + runList(el, 'data-after-always', evt);
155 216 if (!evt.detail.successful) return;
156 -
157 - const form = el as HTMLFormElement;
158 - if (el.hasAttribute('data-hx-reset') && typeof form.reset === 'function') form.reset();
159 - if (el.hasAttribute('data-hx-click')) byId(el.getAttribute('data-hx-click'))?.click();
160 - if (el.hasAttribute('data-hx-get')) {
161 - const url = el.getAttribute('data-hx-get');
162 - const target = el.getAttribute('data-hx-target');
163 - if (url && target) htmx.ajax('GET', url, target);
164 - }
165 - if (el.hasAttribute('data-hx-nav')) window.location.href = el.getAttribute('data-hx-nav') ?? '';
166 - if (el.hasAttribute('data-hx-reload')) window.location.reload();
167 - if (el.hasAttribute('data-hx-toast')) {
168 - showToast(el.getAttribute('data-hx-toast') ?? '', el.getAttribute('data-hx-toast-type') || 'info');
169 - }
170 - const beh = el.getAttribute('data-hx-behavior');
171 - if (beh && BEHAVIORS[beh]) BEHAVIORS[beh](el, evt);
217 + runList(el, 'data-after', evt);
172 218 });
173 219
174 220 document.body.addEventListener('htmx:configRequest', (e) => {
175 221 const evt = e as CustomEvent<HxDetail>;
176 222 const el = evt.detail.elt;
177 - if (el?.getAttribute?.('data-hx-config') === 'publish-at-iso') {
223 + if (el?.getAttribute?.('data-config') === 'publish-at-iso') {
178 224 const params = evt.detail.parameters;
179 225 const v = params?.publish_at;
180 226 if (v && params) params.publish_at = new Date(v as string).toISOString();
@@ -16,7 +16,7 @@
16 16 import { safeGet, safeSet } from './storage.ts';
17 17 import { copyWithFeedback, initCopyLink } from './clipboard.ts';
18 18 import { resolveHtmxLoadingButton, withLoadingState } from './loading.ts';
19 - import { initActionDispatcher, initHxCallbackDispatcher } from './dispatch.ts';
19 + import { initActionDispatcher, initAfterRequestDispatcher } from './dispatch.ts';
20 20 import { setActiveTab, initTabs } from './tabs.ts';
21 21 import { toggleShortcutsHelp, initKeyboard } from './keyboard.ts';
22 22 import { initRestartBanner } from './restart-banner.ts';
@@ -68,7 +68,7 @@
68 68 initToasts();
69 69 initHtmxGlue();
70 70 initActionDispatcher();
71 - initHxCallbackDispatcher();
71 + initAfterRequestDispatcher();
72 72 initCopyLink();
73 73 initKeyboard();
74 74 initTabs();
@@ -44,7 +44,7 @@
44 44 cell.appendChild(warn);
45 45 }
46 46
47 - register('syncKitRegenKeysSecret', function (appId: string) {
47 + register('syncKitRegenKeysSecret', function ([appId]) {
48 48 if (!appId) return;
49 49 if (
50 50 !confirm(
@@ -73,7 +73,7 @@
73 73 hx-put="/api/items/{{ item.id }}/primary-tag"
74 74 hx-vals='{"tag_id": "{{ tag.id }}"}'
75 75 hx-swap="none"
76 - data-hx-always-click="tab-details">&#9734;</button>
76 + data-after-always="click" data-target="tab-details">&#9734;</button>
77 77 {% endif %}
78 78 </span>
79 79 {% endfor %}
@@ -294,7 +294,7 @@
294 294 hx-put="/api/items/{{ item.id }}"
295 295 hx-vals='{"publish_at": ""}'
296 296 hx-confirm="Cancel the scheduled publish?"
297 - data-hx-click="tab-details">Cancel Schedule</button>
297 + data-after="click" data-target="tab-details">Cancel Schedule</button>
298 298 </div>
299 299 {% else if item.is_public %}
300 300 <p class="publish-note">
@@ -305,7 +305,7 @@
305 305 hx-put="/api/items/{{ item.id }}"
306 306 hx-vals='{"is_public": "false"}'
307 307 hx-confirm="Unpublish this item? It will be hidden from public view."
308 - data-hx-click="tab-details">Unpublish Item</button>
308 + data-after="click" data-target="tab-details">Unpublish Item</button>
309 309 </div>
310 310 {% else %}
311 311 <p class="publish-note">
@@ -316,13 +316,13 @@
316 316 hx-put="/api/items/{{ item.id }}"
317 317 hx-vals='{"is_public": "true"}'
318 318 hx-confirm="Publish this item?"
319 - data-hx-click="tab-details">Publish Now</button>
319 + data-after="click" data-target="tab-details">Publish Now</button>
320 320 <button class="btn-secondary" data-action="show" data-target="schedule-form">Schedule</button>
321 321 </div>
322 322 <form id="schedule-form" class="hidden mt-section"
323 323 hx-put="/api/items/{{ item.id }}"
324 - data-hx-config="publish-at-iso"
325 - data-hx-click="tab-details">
324 + data-config="publish-at-iso"
325 + data-after="click" data-target="tab-details">
326 326 <div class="form-group">
327 327 <label for="publish-at">Publish at</label>
328 328 <input type="datetime-local" id="publish-at" name="publish_at" required>
@@ -35,7 +35,7 @@
35 35 hx-confirm="Issue a full refund for {{ sale.amount_display }}? This cannot be undone."
36 36 hx-target="#sale-{{ sale.transaction_id }}"
37 37 hx-swap="outerHTML"
38 - data-hx-get="/dashboard/item/{{ item.id }}/tabs/sales" data-hx-target="#tab-content">Refund</button>
38 + data-after="refresh" data-arg="/dashboard/item/{{ item.id }}/tabs/sales" data-arg2="#tab-content">Refund</button>
39 39 {% endif %}
40 40 </td>
41 41 </tr>