# Whole-form HTMX swap doctrine Every interactive control picks a swap strategy in this **priority order** (prefer the earliest rule that works). Worked examples are the real `transaction/edit.clj` swaps. ## Rule 1 — No request when the field affects nothing else Its value rides along in the form and is read on submit. No `hx-*` at all. ```clojure ;; transaction/edit.clj — the memo field. Editing it issues NO request; the value ;; just rides along until save. The e2e proves zero POSTs fire while typing. (com/text-input {:value (fc/field-value) :name (fc/field-name) :id "edit-memo" :placeholder "Optional note"}) ``` ## Rule 2 — Targeted swap of a single isolated cell when the effect is purely local Give the cell a stable id, keep it **out of the typed input's subtree**, and post the whole form but `hx-select` back only that cell. ```clojure ;; transaction/edit.clj — selecting an account only changes that row's valid Location ;; options, so the change swaps just this cell. Nothing else re-renders. [:div {:id (str "account-location-" index)} ; stable, per-row id (com/validated-field {:x-hx-val:account-id "accountId" :x-dispatch:changed "accountId" ; Alpine fires `changed` when account changes :hx-trigger "changed" :hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed) :hx-target (str "#account-location-" index) :hx-select (str "#account-location-" index) :hx-swap "outerHTML" :hx-include "closest form"} ; whole form posts; only this cell swaps back (location-select* {...}))] ``` ## Rule 3 — Whole-form swap when the change touches interdependent state Vendor change, add/remove row, mode toggle, $/% radio. The form's hidden state rides along, so one swap keeps everything consistent — **no out-of-band swaps**. ```clojure ;; transaction/edit.clj — vendor change rebuilds the whole manual-coding section ;; (vendor default account, terms, etc. are interdependent). [:div {:hx-trigger "change" :hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-vendor-changed) :hx-target "#wizard-form" :hx-select "#wizard-form" :hx-swap "outerHTML" :hx-sync "this:replace" :hx-include "closest form"} ...] ``` The active tab/action round-trips through the form (it's a hidden field bound to Alpine `activeForm`), so it **survives** the whole-form swap — that's why a whole-form swap is safe here even though the user is "on" a tab. ## Rule 4 — OOB only for genuinely disjoint DOM regions A global flash/toast, a nav badge, a modal at the document root. **If tempted to OOB something inside the same feature, restructure instead**: give the dependent element a common ancestor with the trigger and use an ordinary swap. Worked example — running **totals live in their own sibling `
`** so an amount edit swaps the totals without ever replacing the amount input: ```clojure ;; The totals tbody is a sibling of the input-bearing rows. (com/data-grid {:footer-tbody [:tbody {:id "account-totals"} ...totals rows...]} ...account rows with inputs...) ;; The amount input posts the whole form but hx-selects ONLY #account-totals. (com/money-input {:name (fc/field-name) :id (str "account-amount-" index) :class "w-16 account-amount-field" :hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed) :hx-target "#account-totals" ; a SIBLING of this input's row... :hx-select "#account-totals" ; ...so the input is never in the swapped region :hx-swap "outerHTML" :hx-trigger "keyup changed delay:300ms" :hx-include "closest form"}) ``` `grep -c hx-swap-oob` on a migrated modal must be `0` unless a justified disjoint-region case is documented here and in `gotchas.md`. --- ## The focus invariant (must always hold) > The input the user is typing in is never inside the region its own request swaps. This is *the* reason the doctrine works. The amount field swaps a sibling tbody; the memo field swaps nothing; the account typeahead's change swaps the whole form but the typeahead isn't an active text caret at that moment (it's a click-to-select). The `transaction-edit-swap.spec.ts` `sameNode` assertions exist to catch any violation. ## Alpine components must survive swaps When a whole-form swap replaces a region containing Alpine/tippy components, they get re-initialised from the server-provided values. Two hardening moves: 1. **Null-guard every reference** that depends on Alpine/tippy being initialised: ```clojure "@keydown.down.prevent.stop" "tippy?.show()" "@keydown.enter.prevent.stop" "$refs.input?.__x_tippy?.hide(); ..." ``` (`$refs.input?` / `tippy?` — the `?` matters; a swap can run a handler before re-init.) 2. **Let the server value win.** Because the section is rebuilt fresh on each swap, the server-driven value (e.g. a vendor's default account) lands without keying tricks — no preserved stale Alpine state to fight. The "changing the vendor a *second* time still updates it" e2e is the regression guard for this. If you *do* preserve a component across a morph/replace, key it by its server value so a server-driven change forces re-init: `(assoc attrs :key (str id "--" current-value))`. Use `hx/alpine-mount-then-appear` for rows that should mount-then-transition-in (it sets `x-data {data-key false}`, `x-init $nextTick(() => key=true)`, `x-show key`). --- ## Selector strategy for targeted swaps (a consideration, not a mandate) Rules 2 and 4 need a stable `hx-target`/`hx-select`. Per-element unique ids (`#account-location-0`) work and are what transaction-edit uses today. They get noisy in deeply repeated/nested structures. When you hit that (Phase 5 / the wizards), consider: - **Semantic markup + data-attributes** — mark rows/cells with their identity and target by attribute, no per-element ids: ```html