Squashed Phase-2 SSR work: migrate the Transaction Edit modal's render path entirely to Selmer templates (zero Hiccup in the render path), rip out the multi-step wizard abstraction (EditWizard/LinksStep records, MultiStepFormState, step-params[...] field names, mm/* middleware) in favor of a plain form with flat derived state, and promote shared UI components to reusable Selmer partials under resources/templates/components/. Adds the Selmer interop bridge, the auto-ap.ssr.components.selmer (sc) wrapper library, and the ssr-form-migration skill capturing the learnings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
3.5 KiB
Render functions: explicit data, or a top-rooted cursor
One function, data in, markup out. The data can arrive as a plain map or via a cursor — as long as the cursor was rooted at the top of the form and walked down to here, never faked to start at this depth. The rule is about where the cursor starts, not whether you use one.
GOOD — explicit data, pure, testable without setup
(defn account-row [{:keys [account index client-id amount-mode]}]
(com/data-grid-row
(com/hidden {:name (str "accounts[" index "][db/id]")
:value (or (:db/id account) "")})
(com/data-grid-cell
(account-typeahead* {:value (:transaction-account/account account)
:name (str "accounts[" index "][account]")
:client-id client-id}))
...))
ALSO FINE — a cursor that started at the form root and was advanced naturally
;; The top-level render walks the cursor; the row fn receives the dereferenced row
;; (or the advanced cursor). No rebinding of *current*/*prefix* to fake depth.
(defn account-rows [accounts-cursor]
(for [row-cursor (fc/each accounts-cursor)] ; advanced from the root, not faked
(account-row {:account @row-cursor :index (fc/index row-cursor) ...})))
transaction/edit.clj's transaction-account-row* is the cursor form done right: the
caller (account-grid-body*) holds a top-rooted cursor via fc/cursor-map and hands each
row cursor to one render fn.
The SMELL this migration removes
1. Faking the cursor's starting position
A "form cursor" is fine. The pain is rebinding the dynamic root deeper in the tree so
a deeply nested render fn can run against a fragment. Real example from
transaction/edit.clj's simple-mode-fields* (the thing to delete):
;; SMELL: re-roots the cursor to a synthetic MapCursor pointed at accounts[0] so a
;; fragment can render "deep". Fragile, and the source of the *-no-cursor* twin below.
(fc/with-field :transaction/accounts
(fc/with-cursor (let [cur fc/*current*]
(if (sequential? @cur)
(nth cur 0 nil)
(auto_ap.cursor.MapCursor. {} (cursor/state cur)
(conj (cursor/path cur) 0))))
...))
Target: the cursor begins at the top level of what the form consumes and walks down naturally. Because the whole form is re-rendered each time (swap doctrine), there is no longer any reason to fake a deep starting position.
2. The *-no-cursor* twin
Faking the deep cursor forces a second copy of the same markup — one that reads the
faked cursor and one that takes plain params for the cases where the fake can't be set up.
transaction/edit.clj has exactly this pair:
(defn transaction-account-row* [{:keys [value index client-id ...]}] ...) ; cursor form
(defn transaction-account-row-no-cursor* [{:keys [account index client-id ...]}] ...) ; duplicate markup
Fix: keep one render fn. If a caller already holds a top-rooted cursor, advance it and
hand the row data (or the advanced cursor) to that one fn. Delete the *-no-cursor* copy.
Heuristic 1 targets grep -c 'defn.*-no-cursor' → 0 and faked-cursor re-roots → 0.
Scorecard hooks (heuristics 1, 2)
grep -c 'defn.*-no-cursor' $F # → 0
grep -cE 'with-cursor|MapCursor\.' $F # faked re-roots → 0 (top-rooted cursors are fine)
Top-rooted cursors do not count against heuristic 1 — only re-roots that fake depth
and the *-no-cursor* twins do.