Files
integreat/.claude/skills/ssr-form-migration/reference/swap-doctrine.md
Bryce 3ecd115f76 docs(skill): distil ssr-form-migration skill from transaction-edit reference (Phase 1)
Capture the proven whole-form hx-select swap method as a reusable skill so every
later modal migration is cheaper and consistent. No app code changes.

- SKILL.md: the per-migration playbook (classify → baseline → characterize →
  consolidate render fns → templatize → wire HTMX → collapse routes → verify →
  commit → feed skill) + Growth contract + non-negotiables.
- reference/swap-doctrine.md: the four swap rules, focus invariant, Alpine-survives-
  swap hardening, target-selector strategy — worked from the real edit.clj swaps
  (memo no-request, account→location targeted cell, amount→totals sibling-tbody,
  vendor/mode/row whole-form). 0 OOB.
- reference/render-functions.md: explicit-data or top-rooted cursor; the MapCursor
  fake + transaction-account-row-no-cursor* twin as the smell to remove.
- reference/form-vs-wizard.md: classification + the data-driven session-backed
  (formtools SessionStorage) engine that replaces the snapshot round-trip + protocol.
- reference/selmer-conventions.md: STUB, validated in Phase 2.
- component-cookbook.md / gotchas.md / test-recipes.md / scorecard.md: seeded from
  what transaction-edit proves (7 cookbook entries, caret-survival + typeahead test
  recipes, scorecard baseline LOC 1608 / ~12 routes / 1 no-cursor twin / 2 faked
  roots / 0 OOB).

Scorecard (Transaction Edit baseline, before Phase 2): LOC 1608, routes ~12,
no-cursor twins 1, faked-cursor roots 2, snapshot merges ~75, OOB 0, mixed hx- 8.
2026-06-03 00:05:11 -07:00

6.5 KiB

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.

;; 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.

;; 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.

;; 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 <tbody> so an amount edit swaps the totals without ever replacing the amount input:

;; 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:

    "@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:
    <tr data-row="account" data-index="0">
      <td data-cell="location"></td>
    </tr>
    <!-- hx-target="[data-row='account'][data-index='0'] [data-cell='location']" -->
    
  • A form-path -> selector function, derived the same way a cursor path is, so the server and the markup agree on the target by construction. A render fn at form-path [:accounts 0 :location] computes its own stable selector from that path.

Decision status: still per-element ids. The first modal to hit nested repeated swaps (Invoice Bulk Edit, Phase 5) settles the convention and records it here + in component-cookbook.md for the wizards to reuse.