The wizard serialized the whole accumulating form state into a `snapshot` hidden field (pr-str EDN + custom readers), decoded it every request, and merged step-params back in. For this single-step modal the snapshot is pure redundancy: every value is either in the entity or the live posted form. Remove it: - render: EditWizard.render-wizard renders a plain form -- no snapshot / edit-path / current-step hidden fields; a single `db/id` hidden rides in the form instead. - middleware: wrap-derive-state rebuilds :multi-form-state per request from the entity (loaded by the db/id hidden) overlaid with the live step-params, replacing wrap-init-multi-form-state + wrap-entity. The ~34 :snapshot reads are unchanged -- :snapshot is now a derived map, not a round-tripped blob. - editable fields (accounts, vendor, memo, approval, action, mode, amount-mode) come ONLY from the posted form (absent = cleared) so removing all account rows doesn't resurrect the entity's persisted accounts; only entity-only fields (db/id, client, amount, ...) come from the entity. - delete the dead initial-edit-wizard-state and render-account-grid-body. - e2e: make removeAllAccounts re-query each iteration (whole-form swaps stale a captured row index) and restore the percentage test to type-then-add ordering. Scorecard: snapshot EDN round-trip + custom readers + merge-multi-form-state -> gone (snapshot-field renders 0). Verified on a fresh server: full suite 38 pass / 1 unrelated fail, swap 6/6, transaction-edit 8/8 -- same green as before, snapshot removed.
9.2 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.
Scorecard exceptions (ratchet violations with a reason)
None yet. Append here if a migration must let a metric regress for a documented reason.