From 0e02c489e0dcc8febb11d97710bf806c301c79b7 Mon Sep 17 00:00:00 2001 From: Bryce Date: Tue, 2 Jun 2026 22:09:40 -0700 Subject: [PATCH] docs: multi-step wizards use session-stored step state (Django formtools) Replace the EDN snapshot + piecewise merge for multi-step wizards with per-step form state stored in the session, combined only at the end -- the Django formtools WizardView / SessionStorage model. Cite the inspiration and refs. Adds rationale 2.4, reworks the engine snippet in 3.3 to thread session state keyed by wizard-id (no snapshot, no merge), and updates goal 3, the Phase 6 engine tasks, the risk row, and Open decision 1 accordingly. Co-Authored-By: Claude Opus 4.8 --- ...factor-ssr-rendering-modernization-plan.md | 115 +++++++++++++----- 1 file changed, 83 insertions(+), 32 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 6d5fa00c..400b289e 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 @@ -28,7 +28,9 @@ skill* that makes the next migration cheaper. 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. + small data-driven engine instead of protocols + middleware stacking, and + **store each step's data in the session** (combined only at the end) instead + of round-tripping and merging an EDN snapshot — the Django `formtools` model. 4. **Render HTML with Selmer templates** (Jinja-style) instead of Hiccup for the interactive, attribute-heavy components, so Alpine/HTMX attributes are first-class HTML rather than a mix of Clojure keywords and strings. @@ -83,7 +85,15 @@ serialize an EDN snapshot with custom readers into hidden fields, and register 10–20 routes with stacked middleware — all for a single-step form. That is pure overhead. -### 2.4 Hiccup makes Alpine/HTMX attributes ambiguous +### 2.4 Multi-step wizards round-trip and merge a snapshot +The genuine multi-step wizards carry the whole accumulating form state as an EDN +snapshot in hidden fields, then rebuild it each request by merging the posted +pieces back into the snapshot. The serialization needs custom readers, the merge +logic is error-prone, and the page payload grows with every step. The fix is to +**store each step's data in the session under its own key and combine only at the +end** — the Django `formtools` model (§3.3) — so no snapshot is built or merged. + +### 2.5 Hiccup makes Alpine/HTMX attributes ambiguous The same attribute is sometimes a keyword and sometimes a string in the same file, and event handlers must be strings while structural Alpine attrs are keywords. There is no rule a reader (or an LLM) can rely on: @@ -220,7 +230,21 @@ deep node, and never keep a second `*-no-cursor*` copy of the markup. ::route/edit-submit (fn [req] (validate-and-save req))} ``` -- **Genuinely multi-step → data-driven engine.** A wizard is *data*: +- **Genuinely multi-step → data-driven engine with session-stored step state.** + + > **Inspiration — Django `formtools` `WizardView`.** Django's wizard does *not* + > round-trip a serialized blob of the whole form through the page. Each step's + > validated (cleaned) data is written to a **storage backend (the user session + > by default)** under that step's key, and the steps are combined only at the + > very end via `get_all_cleaned_data()`. We adopt the same model: **replace the + > EDN snapshot + piecewise merging with per-step form state stored in the + > session.** A step writes its own data under its own key; nothing is merged + > into a snapshot and nothing about other steps rides through the form. + > Refs: `formtools.wizard.views.WizardView`, its `storage` backends + > (`SessionStorage`), and `get_all_cleaned_data()` + > (https://django-formtools.readthedocs.io/en/latest/wizard.html). + + A wizard is *data*: ```clojure (def vendor-wizard-config {:steps [{:key :info :schema info-schema :fields [...] :render render-info-step @@ -231,41 +255,57 @@ deep node, and never keep a second `*-no-cursor*` copy of the markup. :submit-route "/admin/vendor/wizard/submit" :done-fn (fn [all-data req] (save! all-data) (html-response "Saved"))}) ``` - with a tiny engine (no protocols) and server-side state keyed by a UUID token: + with a tiny engine (no protocols) whose state lives **in the session**, keyed + by a wizard instance id, with each step's data stored under its own step key — + the formtools `SessionStorage` model. No snapshot, no custom EDN readers, no + merge-into-snapshot: ```clojure - ;; state: an atom keyed by wizard-id (add a timestamp + TTL sweep) - (defonce ^:private store (atom {})) - (defn create-wizard! [init] (let [id (str (java.util.UUID/randomUUID))] - (swap! store assoc id {:current-step (-> init :steps first :key) - :step-data {} :created-at (System/currentTimeMillis)}) - id)) - (defn update-step! [id k data] (swap! store update-in [id :step-data k] merge data)) - (defn get-all [id] (apply merge (vals (:step-data (@store id))))) + ;; Storage backed by the Ring session (replaces the hidden EDN snapshot). + ;; Path in session: [:wizards :step-data ] + (defn create-wizard! [session config] + (let [id (str (java.util.UUID/randomUUID))] + [id (assoc-in session [:wizards id] + {:current-step (-> config :steps first :key) :step-data {}})])) - (defn render-wizard [{:keys [wizard-id config request]}] - (let [{:keys [current-step step-data]} (@store wizard-id) + (defn put-step [session id k data] (assoc-in session [:wizards id :step-data k] data)) ; replace, not merge + (defn set-step [session id k] (assoc-in session [:wizards id :current-step] k)) + (defn get-all [session id] (->> (get-in session [:wizards id :step-data]) vals (apply merge))) + (defn forget [session id] (update session :wizards dissoc id)) + + (defn render-wizard [{:keys [wizard-id config session request]}] + (let [{:keys [current-step step-data]} (get-in session [:wizards wizard-id]) step (first (filter #(= (:key %) current-step) (:steps config)))] [:form#wizard-form {:hx-post (:submit-route config) :hx-target "#wizard-form" :hx-select "#wizard-form" :hx-swap "outerHTML"} + ;; only a reference token rides in the form -- not the form's state (com/hidden {:name "wizard-id" :value wizard-id}) (com/hidden {:name "current-step" :value (name current-step)}) ((:render step) (assoc request :step-data (get step-data current-step {})))])) - (defn handle-step-submit [config request] + ;; Handlers thread the (possibly updated) session back into the Ring response. + (defn handle-step-submit [config {:keys [session] :as request}] (let [{:strs [wizard-id current-step]} (:form-params request) step (first (filter #(= (:key %) (keyword current-step)) (:steps config))) data (select-keys (:form-params request) (map name (:fields step)))] (if-let [errors (mc/explain (:schema step) data)] - (render-wizard {:wizard-id wizard-id :config config :request (assoc request :errors errors)}) - (do (update-step! wizard-id (keyword current-step) data) - (let [nxt ((:next step) data)] - (if (= nxt :done) - (let [all (get-all wizard-id)] (swap! store dissoc wizard-id) ((:done-fn config) all request)) - (do (swap! store assoc-in [wizard-id :current-step] nxt) - (render-wizard {:wizard-id wizard-id :config config :request request})))))))) + (-> (render-wizard {:wizard-id wizard-id :config config :session session + :request (assoc request :errors errors)}) + html-response) + (let [session' (put-step session wizard-id (keyword current-step) data) + nxt ((:next step) data)] + (if (= nxt :done) + (-> ((:done-fn config) (get-all session' wizard-id) request) ; combine only at the end + (assoc :session (forget session' wizard-id))) + (let [session'' (set-step session' wizard-id nxt)] + (-> (html-response (render-wizard {:wizard-id wizard-id :config config + :session session'' :request request})) + (assoc :session session'')))))))) ``` Two routes per wizard: open (`partial open-wizard config`) and submit - (`partial handle-step-submit config`). + (`partial handle-step-submit config`). State is namespaced by `wizard-id` inside + the session, so multiple in-flight wizards (and tabs) don't collide, and it is + discarded on completion (`forget`). See Open decision 1 for the storage-backend + choice (Ring session store vs. a durable store for long-lived wizards). ### 3.4 Selmer templates @@ -556,15 +596,22 @@ apply it cold." Single-step form currently wearing a wizard costume. ### Phase 6 — Build the wizard engine + migrate Transaction Rule (2-step wizard) **Rationale:** the first genuinely multi-step modal, and the simplest one — the -right place to introduce the data-driven engine (§3.3) and server-side state. +right place to introduce the data-driven engine (§3.3) and **session-stored +per-step state** (the Django `formtools` model), replacing the EDN snapshot + +merge. **Engine (do once, here):** -- [ ] Create `components/wizard_state.clj` (atom store, `create-wizard!`, - `update-step!`, `get-all`, `destroy!`, **TTL sweep** for abandoned wizards). - Test the lifecycle via REPL. +- [ ] Create `components/wizard_state.clj` backed by the **Ring session**: + `create-wizard!`, `put-step` (replace step data, do **not** merge into a + snapshot), `set-step`, `get-all` (combine only at the end), `forget`. State is + namespaced by `wizard-id` inside the session (`[:wizards ...]`) so tabs + and concurrent wizards don't collide. Each fn returns the updated session for + the handler to thread into the Ring response. Test the lifecycle via REPL. - [ ] Create `components/wizard2.clj` (`render-wizard`, `handle-step-submit`, - `open-wizard`). Test render + step navigation. -- [ ] Document the engine usage in `reference/form-vs-wizard.md`. + `open-wizard`) — engine threads session through and only `wizard-id` rides in + the form. Test render + step navigation + that no snapshot is emitted. +- [ ] Document the engine usage and the formtools inspiration in + `reference/form-vs-wizard.md`. **Modal migration (run the §8 loop), specifics:** - [ ] Extract `render-edit-step` and `render-test-step` (the test step shows a @@ -663,7 +710,7 @@ matches, emails, contact methods). Deliberately last, when the skill is richest. | Risk | Mitigation | |------|------------| -| Server restart loses in-flight wizard state | Server state only for true multi-step wizards; forms hold none. TTL + sweep; consider a durable store if a wizard is long-lived. | +| In-flight wizard state lost (restart / session expiry) | State lives in the session (formtools model), scoped to true multi-step wizards; plain forms hold none. Lifetime follows the session; for long-lived wizards choose a durable session backend or store (Open decision 1). `forget` on completion prevents session bloat. | | Mixed Hiccup/Selmer interop gets messy | Build + prove the interop bridge in Phase 2 before broad use; strangler keeps both valid. | | Selmer loses Hiccup's structural testability | Lean on e2e + DB assertions; unit-test the data-prep functions, not markup. | | Large files hide behavior (`clients.clj`, `edit.clj`) | They go last, after the skill is rich; characterization e2e first; split per step. | @@ -676,8 +723,12 @@ matches, emails, contact methods). Deliberately last, when the skill is richest. ## 11. Open decisions -1. **Server state scope** — server-side state only for multi-step wizards, none - for plain forms? *(recommended: yes)* +1. **Wizard state storage** — store multi-step state in the **Ring session** + (Django `formtools` `SessionStorage` model), keyed by `wizard-id`, none for + plain forms? Confirm the session backend in use (in-memory vs. durable) is + acceptable for in-flight wizard lifetime, or pick a durable store for + long-lived flows. *(recommended: session storage, scoped to multi-step + wizards only)* 2. **Selmer scope** — convert only interactive/attribute-heavy components first (hybrid), or all SSR files (full sweep)? *(recommended: hybrid, revisit in Phase 11)*