The :manual save handler builds its tx-data from the wizard snapshot and stripped the control fields :action and :amount-mode, but not :mode (simple/advanced) added by the recent manual-coding work. manual-coding-section* emits step-params[mode] on every render, so EVERY advanced manual save posted :mode "advanced" into :upsert-transaction and 500'd with ":db.error/not-an-entity :mode". Strip :mode alongside :action so the upsert only sees real schema attributes. Also fix the e2e helper that masked this: selectAccountFromTypeahead poked the Alpine v2 internal `el.__x.$data`, which is undefined on Alpine v3 (this app loads alpinejs@3.x), so it silently no-op'd and the account posted empty. Drive the typeahead via the real Alpine v3 path (Alpine.$data + tippy dropdown + click), mirroring transaction-edit-swap. Unmasks the previously-failing "Shared Location spread on save" test (was first in a serial file, hiding 7 siblings). Verified: that test passes; transaction-edit-swap stays 6/6. Skill gotchas.md records the :mode-strip rule, the Alpine-v3 API requirement, and the modal-won't-close diagnosis recipe.
4.7 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.
Scorecard exceptions (ratchet violations with a reason)
None yet. Append here if a migration must let a metric regress for a documented reason.