From 917b7f3857515da65c2212ede95895dbb7b35ffb Mon Sep 17 00:00:00 2001 From: Bryce Date: Tue, 2 Jun 2026 22:02:25 -0700 Subject: [PATCH] docs: clarify cursors are fine; only faked positions are the smell Reframe goal 2, the rationale (2.2), the render-function pattern (3.2), and scorecard heuristic 1 so the target is top-rooted cursors. Cursors stay; what we remove is faking a cursor to start deeper in the tree and the duplicate *-no-cursor* variants that fakery forces. Co-Authored-By: Claude Opus 4.8 --- ...factor-ssr-rendering-modernization-plan.md | 73 +++++++++++++------ 1 file changed, 51 insertions(+), 22 deletions(-) diff --git a/docs/plans/2026-06-02-001-refactor-ssr-rendering-modernization-plan.md b/docs/plans/2026-06-02-001-refactor-ssr-rendering-modernization-plan.md index c0bbd723..6d5fa00c 100644 --- a/docs/plans/2026-06-02-001-refactor-ssr-rendering-modernization-plan.md +++ b/docs/plans/2026-06-02-001-refactor-ssr-rendering-modernization-plan.md @@ -15,11 +15,17 @@ skill* that makes the next migration cheaper. ## 1. Goals 1. **Render forms by re-rendering the whole form** (or a precise, isolated - fragment) over HTMX, instead of mutating the DOM in place. This removes the - class of bugs around stale state, lost focus/caret, and out-of-band patching. -2. **Make render functions pure.** A render function takes an explicit data map - and returns markup. No dynamic bindings, no "cursor" context, no duplicate - `*-no-cursor*` variants. + fragment) over HTMX, using hx-select to choose elements, instead of mutating + the DOM in place. This removes the class of bugs around stale state, lost + focus/caret, and out-of-band patching. +2. **Root cursors at the top; never fake their position.** Cursors are fine and + stay — a render function may take an explicit data map *or* a cursor. What we + remove is the practice of **faking a cursor to start deeper** in the tree to + satisfy a partial render, and the duplicate `*-no-cursor*` variants that + fakery forces. The target: a cursor always begins at the top level of what the + form consumes and walks down naturally from there. (Because the whole form is + re-rendered each time, there is no longer any reason to fake a deep starting + position.) 3. **Stop forcing single-step forms through wizard machinery.** Most "wizards" are single-step; they become plain forms. Genuine multi-step flows use a small data-driven engine instead of protocols + middleware stacking. @@ -44,13 +50,17 @@ form** and letting the typed value ride along in the form is simpler and correct, *provided the input the user is typing in is never inside the region being swapped*. -### 2.2 Cursor-based rendering forces duplicate functions -Render code that reads from dynamic bindings (a "form cursor") is -context-dependent and hard to test, which has spawned duplicate render functions -— one that reads the cursor and one that takes plain params: +### 2.2 Faking cursor positions forces duplicate functions +A "form cursor" itself is fine. The pain comes from **faking the cursor's +starting position** — rebinding the dynamic root deeper in the tree so a deeply +nested render function can run against a fragment. That fakery is fragile and +hard to follow, and it has spawned duplicate render functions: one that reads the +faked cursor and one that takes plain params for the cases where the fake can't +be set up. ```clojure -;; SMELL: needs cursor context (dynamic bindings *form-data* / *current* / *prefix*) +;; SMELL: this render fn assumes the cursor was faked to start deep at an account, +;; so it only works when *current*/*prefix* were rebound to point there first. (defn account-row* [{:keys [value client-id]}] (com/data-grid-row (fc/with-field :transaction-account/account @@ -58,11 +68,15 @@ context-dependent and hard to test, which has spawned duplicate render functions (account-typeahead* {:value (fc/field-value) :name (fc/field-name)}))) ...)) -;; SMELL: a second copy of the same markup, just to avoid the cursor +;; SMELL: a second copy of the same markup, just to avoid the faked-deep cursor (defn account-row-no-cursor* [{:keys [account index client-id]}] ...) ``` +**Target:** the cursor starts at the top of the form's data and walks down +naturally; a row render either takes explicit row data or receives a cursor the +caller advanced step-by-step from the root — never one teleported to a deep node. + ### 2.3 Single-step forms wear wizard costumes Several forms implement a multi-step wizard protocol (5 protocols, 15+ methods), serialize an EDN snapshot with custom readers into hidden fields, and register @@ -163,9 +177,11 @@ state: (assoc attrs :key (str id "--" current-value)) ``` -### 3.2 Pure render functions +### 3.2 Render functions: explicit data, or a top-rooted cursor -One function, explicit data in, markup out: +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. ```clojure ;; GOOD: pure, works everywhere, testable without setup @@ -180,8 +196,19 @@ One function, explicit data in, markup out: ...)) ``` -If a caller still has a cursor, give it a *thin* wrapper that adapts cursor → -data and calls the pure function. Never duplicate the markup. +```clojure +;; 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) ...}))) +``` + +The rule is about *where the cursor starts*, not whether you use one. If a caller +already holds a top-rooted cursor, advance it and hand the row data (or the +advanced cursor) to one render function. Never rebind the cursor to teleport to a +deep node, and never keep a second `*-no-cursor*` copy of the markup. ### 3.3 Forms vs. wizards (and the data-driven wizard engine) @@ -301,7 +328,7 @@ convention, e.g. `.claude/skills/testing-conventions/SKILL.md`). SKILL.md # the playbook (§8): classify → migrate → verify → record reference/ swap-doctrine.md # §3.1 rules, focus invariant, OOB-vs-hoist, Alpine hardening - pure-render.md # §3.2 pure functions + thin cursor adapters + render-functions.md # §3.2 explicit-data or top-rooted cursor; no faked positions form-vs-wizard.md # §3.3 classification + the data-driven engine selmer-conventions.md # §3.4 attr style, interop bridge, include/block patterns component-cookbook.md # GROWS: typeahead, account-row, totals, money-input, mode-toggle… @@ -331,7 +358,7 @@ the touched modal.** | # | Heuristic | Measure | Target | |---|-----------|---------|--------| -| 1 | Form-cursor / dynamic-binding usage | `grep -cE 'fc/with-field|\*form-data\*|\*current\*|\*prefix\*|-no-cursor'` | → 0 | +| 1 | Faked cursor positions (not cursors themselves) | count cursor-root rebinds (`binding` of `*current*`/`*prefix*`/`*form-data*`, or `with-field`/`with-*` used to *re-root* deeper) + `grep -c '\-no-cursor'` | → 0 (top-rooted cursors are fine) | | 2 | Implicit state merges (snapshot/cursor) | count merge sites | → 0 (forms); explicit `update-step!` only (wizards) | | 3 | Branching complexity | `clj-kondo`, or count `cond`/`condp`/`case`/nested `if` + max depth | net ↓ | | 4 | Lines of code | `wc -l` on the modal's file(s) | net ↓ | @@ -377,8 +404,9 @@ Modal phases below list only what is *specific* to that modal plus this loop. wizard (engine + server state). When in doubt, it's a form. 3. [ ] **Baseline the scorecard (§6).** Record before-numbers. 4. [ ] **Characterize behavior (test-first).** Write/confirm the e2e spec. -5. [ ] **Extract pure render functions** (kills cursor faking and `*-no-cursor*` - duplicates — heuristics 1, 2). +5. [ ] **Consolidate render functions** so they take explicit data or a + top-rooted cursor — remove faked cursor positions and `*-no-cursor*` + duplicates (heuristics 1, 2). Using a cursor is fine; faking its start is not. 6. [ ] **Templatize in Selmer**; reuse cookbook bits, add new ones back (heuristics 5, 8). 7. [ ] **Wire HTMX per the swap doctrine** (§3.1); focus invariant intact; OOB @@ -423,7 +451,8 @@ acceptable prerequisite.) - [ ] Write `reference/swap-doctrine.md` from §3.1 (the four rules, focus invariant, OOB-vs-hoist, Alpine hardening), using the real transaction-edit swaps as worked examples. -- [ ] Write `reference/pure-render.md` from §3.2. +- [ ] Write `reference/render-functions.md` from §3.2 (explicit data or a + top-rooted cursor; remove faked positions and `*-no-cursor*` duplicates). - [ ] Write `reference/form-vs-wizard.md` from §3.3 (classification + engine). - [ ] Stub `reference/selmer-conventions.md` from §3.4, marked "validated in Phase 2." @@ -487,8 +516,8 @@ apply it cold." Single-step form currently wearing a wizard costume. - [ ] Collapse 4 wizard routes → 2 (`GET` open, `POST` submit). - [ ] Verify bulk-code applies correctly (assert DB) + full suite green. - [ ] Feed the skill; append scorecard row. -- [ ] **Exit criteria:** ≥2 cookbook entries reused; LOC, route-count, cursor-use - all down vs. baseline. +- [ ] **Exit criteria:** ≥2 cookbook entries reused; LOC, route-count, and + faked-cursor count all down vs. baseline. ---