# 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 `
` 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)` then `expect(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 ``. Under the wizard, `method` lived *outside* `step-params[...]` so it never entered the decoded map. After the rename it decodes to `:method ""` (malli `:map` is open and passes unknown keys), rides into `snapshot` → `tx-data`, and `:upsert-transaction` rejects it → **HTTP 500 on save**. - Symptom: the save POST fires (confirm with a `println` in the submit handler) but the modal never closes, because the 500 trips `htmx:response-error`. The server error may go to mulog, not stdout — an empty stdout log does **not** mean "no error." Reproduce the exact POST with `curl` (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 `def`s (`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.