Files
integreat/.claude/skills/ssr-form-migration/reference/gotchas.md
Bryce 69eed1f8a6 fix(ssr): strip UI-only :mode before transaction upsert (500 on advanced manual save)
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.
2026-06-03 06:05:42 -07:00

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 goneel.__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.