Migrate every part of the Transaction Edit modal's HTML to Selmer templates
(zero Hiccup in the render path) and delete the mm multi-modal "wizard"
abstraction entirely -- there was only ever one step.
- New auto-ap.ssr.components.selmer (sc) + ~22 shared component partials under
resources/templates/components/ (typeahead, button-group, radio-card,
data-grid, validated-field, modal, buttons, inputs, SVGs). Each wrapper renders
its own partial; dynamic HTMX/Alpine attrs bridge via attrs->str -> {{attrs|safe}}.
- 15 modal templates under resources/templates/transaction-edit/.
- Delete EditWizard/LinksStep records + all mm/* usage. Plain handlers: flat
wrap-decode-edit (fields renamed off step-params[...], stray keys stripped),
flat wrap-derive-state, *errors*-based field errors, generic wrap-form-4xx-2.
- Drop the edit-wizard-navigate route (routes ~12 -> 5).
- Fix: stray `method` (tab button-group hidden) leaked into the upsert -> 500;
strip decoded map to schema keys.
- e2e selectors updated (#wizard-form->#edit-form, #wizardmodal->#editmodal,
step-params[...] field names). Parity: swap 6/6, edit 8/8, suite 38/1
(1 pre-existing unrelated nav test).
- ssr-form-migration skill updated with the learnings (composition mechanics,
sc/* library, drop-the-wizard recipe, scorecard row, 3 new gotchas).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
12 KiB
Gotchas
GROWS every migration. One entry per surprise. Also the home for any written exception to the scorecard ratchet (a metric that regressed for a documented reason).
Stale $refs / tippy after a swap
A whole-form swap can run an Alpine event handler before the component re-initialises,
so a handler that dereferences $refs.input.__x_tippy or calls tippy.show() throws.
Always null-guard: $refs.input?.__x_tippy?.hide(), tippy?.show(). The
transaction-edit-swap.spec.ts trackErrors() helper fails the test on any pageerror
or console.error, which is exactly how a stale-ref throw surfaces.
Let the server value win — don't preserve Alpine state across a server-driven change
When a server change should update a component (e.g. choosing a vendor sets its default
account), rebuild that section fresh on the swap so the server-provided value lands
without keying tricks. The bug this prevents: "changing the vendor a second time doesn't
update the account" because preserved Alpine state shadowed the new server value. If you
must preserve a component, key it by value so a change forces re-init:
(assoc attrs :key (str id "--" current-value)).
Focus dies if the typed input is inside its own swapped region
The single most important invariant. Amount field → swap a sibling tbody, not the row.
Memo → swap nothing. If a caret test (sameNode) fails, the input is in its own swap
region — re-target to a sibling/ancestor that excludes it.
Faked cursors breed duplicate render fns
A with-cursor/MapCursor re-root to fake a deep start forces a *-no-cursor* twin.
Removing the fake lets you delete the twin. Don't "fix" a faked cursor in place — top-root
it and collapse to one render fn. (See render-functions.md.)
Edit Clojure with clojure-mcp tools, not the file editor
clojure_edit / clojure_edit_replace_sexp. If a file won't compile: clj-paren-repair
the file, then retry; if still broken, lein cljfmt check. Run tests via clojure-eval /
clj-nrepl-eval -p PORT, never lein test (slow, last resort).
Solr/typeahead in tests
Account/vendor search is backed by Solr, unavailable in tests. To drive a typeahead in
e2e: type under the 3-char threshold, then inject a result into Alpine state
(Alpine.$data(el).elements = [{value, label}]) and click it — the real click handler,
tippy.hide(), Alpine reactivity, and the HTMX swap all run as in production. Entity ids
come from GET /test-info.
UI-only control fields must be stripped before a Datomic upsert
The wizard snapshot/step-params carry UI control fields that are not schema
attributes — :action, :amount-mode, and (added by the simple/advanced work) :mode.
The :manual save handler stripped :action/:amount-mode but not :mode, so every
advanced manual save passed :mode "advanced" into :upsert-transaction and 500'd with
:db.error/not-an-entity :mode. Lesson: when a save derives its tx-data from the form
snapshot, strip every non-schema control key before transacting. The session-backed
wizard engine (Phase 6) avoids this class of bug by storing per-step validated data
only — UI control fields never enter the combined data. This was a real production bug
surfaced by the e2e gate, not a test artifact.
E2E helpers must use the Alpine v3 API, not the v2 __x internal
The app loads Alpine v3 (cdn.jsdelivr.net/npm/alpinejs@3.x.x). The v2 internal
el.__x.$data is gone — el.__x is undefined, so any helper that pokes it silently
no-ops. A stale selectAccountFromTypeahead did this and left the posted account empty
(account-controlled by x-model, so the raw DOM .value you set is overwritten from
Alpine's empty state). Drive components the real way instead: window.Alpine.$data(el),
open the tippy dropdown, inject elements, click the result — exactly as
transaction-edit-swap.spec.ts does. Probe with
{ hasLegacy__x: !!el.__x, hasAlpineData: !!window.Alpine.$data(el) }.
Diagnosing a "modal won't close after save"
The edit modal closes on an hx-trigger: modalclose from a successful save; a
validation failure re-renders the #wizard-form (200), and a server exception returns 500
(caught by wrap-error). To find which: capture POST responses in Playwright
(page.on('response', …)), read the edit-submit body — a <form id="wizard-form"> means
validation re-render; a #error {…} stack means a 500. Then serialize the form right
before save (new FormData(document.querySelector('#wizard-form'))) to see exactly what
posts. This is how the :mode 500 and the empty-account bugs above were isolated.
De-faking a cursor is not a drop-in — with-field-default mutates
Tempting fix for a faked deep cursor (with-cursor + synthetic MapCursor at index 0):
replace it with (fc/with-field-default 0 {}) to advance naturally. It broke the
simple-mode swap (transaction-edit-swap test 1 threw). with-field-default calls
cursor/transact! — it mutates the form cursor (assoc-ing the default row) as a render
side effect, which changes simple-mode behavior. The read-only synthetic MapCursor did
not. Lesson: removing a faked cursor on these modals is not a one-liner — it's part of
the larger render-fn extraction (render the row from explicit data, construct field names
directly, look up errors explicitly), done when the simple/advanced rows are reworked into
pure render fns / Selmer. Don't swap one cursor primitive for another and assume parity;
verify against the swap spec, and expect the de-fake to come with the render-fn rewrite.
Snapshot operations read stale state and drop live form values (heuristic 2)
The whole-form operation handlers (apply-new-account, apply-remove-account,
apply-toggle-amount-mode) rebuild the account rows from the decoded :snapshot (the
hidden EDN field), not from the live posted :step-params. So any value the user has typed
but that hasn't been re-serialised into the snapshot yet — e.g. an amount typed right
before clicking "New account" — is silently lost when the operation re-renders. This is
the snapshot round-trip fragility the migration removes (heuristic 2: → 0 merges; state
should ride in the form, not a parallel snapshot). It bit the percentage-split e2e: typing
50% then adding a row reverted the first row to its snapshot value, yielding a 66.67/33.33
split. Two ways it shows up and how to handle until the snapshot is gone:
Fixed (Stage 1): the operation handlers read the live :step-params rows (already
schema-decoded by mm/wrap-wizard) so typed values survive add/remove/toggle.
Done (Stage 2 — the snapshot round-trip is gone). The EDN snapshot hidden field +
custom readers + merge-multi-form-state are removed. A db/id hidden rides in the form;
wrap-derive-state rebuilds :multi-form-state per request from entity ∪ step-params,
and EditWizard.render-wizard renders a plain form (no snapshot/edit-path/current-step
hiddens). The ~34 :snapshot reads still work — :snapshot is now a derived map, not a
round-tripped blob.
Trap that cost hours — derive entity ∪ step-params correctly. First cut was
(merge base step-params). Bug: base always carries the entity's persisted accounts,
so after the user removes every row (step-params has no accounts key) the merge falls back
to base → the persisted accounts resurrect on the next operation. Fix: editable fields
(accounts, vendor, memo, approval, action, mode, amount-mode) come only from the live
form (absent = cleared); only entity-only fields (db/id, client, amount, description,
status, type) come from the entity. Lesson: with a posted form, "field absent" means
cleared, not "use the persisted value" — never merge the entity's editable fields back in.
Verify the snapshot removal on a FRESH server, and don't trust a long-lived in-process
test server. Protocol/defrecord (EditWizard.render-wizard) and middleware reloads do
not fully take in a running REPL — the server kept rendering the old snapshot field
after :reload, and an in-process server that isn't reseeded between npx playwright
invocations accumulates state that makes order-dependent tests flake. Both produced hours
of phantom failures. Restart the REPL clean (or reseed) before trusting an e2e result; CI
boots a fresh server per run, so the fresh-server number (38 pass / 1 unrelated) is the real one.
Characterization tests rot against table order and removed wizard chrome
Two stale-test traps surfaced once the masking failure was fixed (a mode: 'serial' file
hides every test after the first failure, so fixing one unmasks the next):
- Hard-coded amounts per table row index (
openEditModal(page, 3)thenexpect(amount).toBeCloseTo(400)) break because same-date seed transactions have no pinned row order. Read the actual value (e.g. the grid's.account-grand-total-row) instead of hard-coding. - Helpers that navigate the old multi-step wizard (
click('button:has-text("Transaction Actions")')) hang once the modal is single-page. Drop the navigation; the action tabs are present immediately.
Flat decode leaks stray form fields into the saved entity (the method 500)
Dropping the wizard's step-params[...] field-name prefix and decoding posted params
straight into the form schema means the decode now captures every posted field, not
just the namespaced ones. A single stray field breaks the save:
- The tab switcher is
(com/button-group {:name "method"} …), which emits<input type="hidden" name="method">. Under the wizard,methodlived outsidestep-params[...]so it never entered the decoded map. After the rename it decodes to:method ""(malli:mapis open and passes unknown keys), rides intosnapshot→tx-data, and:upsert-transactionrejects it → HTTP 500 on save. - Symptom: the save POST fires (confirm with a
printlnin the submit handler) but the modal never closes, because the 500 tripshtmx:response-error. The server error may go to mulog, not stdout — an empty stdout log does not mean "no error." Reproduce the exact POST withcurl(add/remove one field) to isolate the offender fast.
Fix: strip the decoded map to the schema's known top-level keys before threading on
(select-keys decoded edit-form-keys); keep that allowlist next to the schema. Nested
account sub-maps decode fine — only the top level needs the guard.
REPL reload does not refresh a running jetty's routes — restart the JVM
handler/match->handler-lookup is a top-level def capturing (merge ssr/key->handler …)
at load, through a chain of module-level defs (edit → ssr.transaction → ssr.core →
handler). Reloading the leaf edit.clj updates it but not the captured merges, and a
jetty started (run-jetty app …) holds a static app that doesn't re-deref the lookup per
request. Net: after a handler/route/record change, an already-running dev server keeps
serving the old code — curl shows the pre-change response (e.g. the old wizard
transitioner) while your REPL renders the new one. Restart in a fresh JVM for
route/record/middleware changes. For e2e, the Playwright test server
(lein run -m auto-ap.test-server) is a fresh JVM compiling from disk — but kill any stale
:3333 first (reuseExistingServer reuses it), and kill by port
(ss -tlnp | grep :3333), never pkill -f test-server (it matches its own command line).
Full-suite e2e flakes are shared-seed interference
The test server seeds once at boot; edit tests save (mutate) those seed transactions.
Run in parallel, workers race the same rows and earlier saves pollute later reads → phantom
failures that pass in isolation. Clean signal: restart (re-seed) + --workers=1.
Baseline is 38 pass / 1 fail, the 1 being the pre-existing
transaction-navigation.spec.ts:92 date-range test (unrelated to the edit modal).
Scorecard exceptions (ratchet violations with a reason)
Heuristic 9 (Hiccup in render path) — partial exception (Phase 2-final). The post-save
com/success-modal confirmation dialogs in save-handler keep ~6 [:p …] Hiccup lines.
They are terminal responses (shown after the form closes), reuse a shared dialog component,
and sit outside the form's interactive render path. Migrating them means porting the shared
success-modal to Selmer — a Phase 11 cross-cutting task, not a single-modal one.