From 6134bd2ea5e3a10cd643a82e715806fe55ae4166 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 29 Mar 2026 18:27:08 +0000 Subject: [PATCH] Stepper persistence: use def-store instead of broken cookies The home stepper's step-idx signal was not persisting across SX navigation because set-cookie/freeze-to-sx wasn't working in the WASM kernel. Replace with def-store which uses a global registry that survives island re-hydration. Also fix sx_http.exe build: add sx_http back to dune, inline scope primitives (Sx_scope module was removed), add declarative form stubs and render stubs, fix /sx/ home route mapping. Co-Authored-By: Claude Opus 4.6 (1M context) --- hosts/ocaml/bin/dune | 2 +- hosts/ocaml/bin/sx_http.ml | 58 ++++++++++++++++++++++++++++- sx/sx/home-stepper.sx | 28 ++++---------- tests/playwright/navigation.spec.js | 40 ++++++++++++++++++++ 4 files changed, 104 insertions(+), 24 deletions(-) diff --git a/hosts/ocaml/bin/dune b/hosts/ocaml/bin/dune index b94d477f..ec250483 100644 --- a/hosts/ocaml/bin/dune +++ b/hosts/ocaml/bin/dune @@ -1,5 +1,5 @@ (executables - (names run_tests debug_set sx_server integration_tests) + (names run_tests debug_set sx_server sx_http integration_tests) (libraries sx unix)) (executable diff --git a/hosts/ocaml/bin/sx_http.ml b/hosts/ocaml/bin/sx_http.ml index 48e730c0..bfc5ab84 100644 --- a/hosts/ocaml/bin/sx_http.ml +++ b/hosts/ocaml/bin/sx_http.ml @@ -74,7 +74,61 @@ let setup_io_stubs env = let make_http_env () = let env = make_env () in Sx_render.setup_render_env env; - Sx_scope.setup_scope_env env; + (* Scope primitives — inline since Sx_scope was merged *) + let _scope_stacks : (string, Sx_types.value list) Hashtbl.t = Hashtbl.create 8 in + let bind name fn = ignore (Sx_types.env_bind env name (Sx_types.NativeFn (name, fn))) in + bind "scope-push!" (fun args -> match args with + | [String name; value] -> let s = try Hashtbl.find _scope_stacks name with Not_found -> [] in Hashtbl.replace _scope_stacks name (value :: s); Nil + | [String name] -> let s = try Hashtbl.find _scope_stacks name with Not_found -> [] in Hashtbl.replace _scope_stacks name (Nil :: s); Nil + | _ -> Nil); + bind "scope-pop!" (fun args -> match args with + | [String name] -> (match (try Hashtbl.find _scope_stacks name with Not_found -> []) with _ :: rest -> Hashtbl.replace _scope_stacks name rest | [] -> ()); Nil + | _ -> Nil); + bind "scope-peek" (fun args -> match args with + | [String name] -> (match (try Hashtbl.find _scope_stacks name with Not_found -> []) with v :: _ -> v | [] -> Nil) + | _ -> Nil); + bind "scope-emit!" (fun args -> match args with + | [String name; value] -> + let key = name ^ ":emitted" in + let s = try Hashtbl.find _scope_stacks key with Not_found -> [] in + Hashtbl.replace _scope_stacks key (value :: s); Nil + | _ -> Nil); + bind "scope-emitted" (fun args -> match args with + | [String name] -> + let key = name ^ ":emitted" in + let items = try Hashtbl.find _scope_stacks key with Not_found -> [] in + Hashtbl.replace _scope_stacks key []; List (List.rev items) + | _ -> List []); + bind "collect!" (fun args -> match args with + | [String name; value] -> + let key = name ^ ":collected" in + let s = try Hashtbl.find _scope_stacks key with Not_found -> [] in + Hashtbl.replace _scope_stacks key (value :: s); Nil + | _ -> Nil); + bind "collected" (fun args -> match args with + | [String name] -> + let key = name ^ ":collected" in + let items = try Hashtbl.find _scope_stacks key with Not_found -> [] in + Hashtbl.replace _scope_stacks key []; List (List.rev items) + | _ -> List []); + (* Declarative form stubs — no-ops at runtime *) + bind "define-module" (fun _args -> Nil); + bind "define-primitive" (fun _args -> Nil); + bind "deftype" (fun _args -> Nil); + bind "defeffect" (fun _args -> Nil); + bind "deftest" (fun _args -> Nil); + bind "defstyle" (fun _args -> Nil); + bind "defhandler" (fun _args -> Nil); + bind "defpage" (fun _args -> Nil); + bind "defquery" (fun _args -> Nil); + bind "defaction" (fun _args -> Nil); + bind "defrelation" (fun _args -> Nil); + (* Render stubs *) + bind "set-render-active!" (fun _args -> Nil); + bind "render-active?" (fun _args -> Bool true); + bind "trampoline" (fun args -> match args with + | [Thunk (expr, e)] -> Sx_ref.eval_expr expr (Env e) + | [v] -> v | _ -> Nil); (* Setup all the standard primitives *) (* Evaluator bridge — needed for aser, macro expansion *) ignore (env_bind env "eval-expr" (NativeFn ("eval-expr", fun args -> @@ -236,7 +290,7 @@ let sx_render_to_html expr env = let render_page env statics path = let t0 = Unix.gettimeofday () in (* Build the page AST: evaluate the URL path as an SX expression *) - let path_expr = if path = "/" || path = "" then "home" + let path_expr = if path = "/" || path = "" || path = "/sx/" || path = "/sx" then "home" else begin (* /sx/(geography.(reactive)) → (geography (reactive)) *) let p = if String.length path > 4 && String.sub path 0 4 = "/sx/" then diff --git a/sx/sx/home-stepper.sx b/sx/sx/home-stepper.sx index 86c70dd1..39617810 100644 --- a/sx/sx/home-stepper.sx +++ b/sx/sx/home-stepper.sx @@ -2,9 +2,11 @@ ~home/stepper () (let - ((source "(div (~cssx/tw :tokens \"text-center\")\n (h1 (~cssx/tw :tokens \"text-3xl font-bold mb-2\")\n (span (~cssx/tw :tokens \"text-rose-500\") \"the \")\n (span (~cssx/tw :tokens \"text-amber-500\") \"joy \")\n (span (~cssx/tw :tokens \"text-emerald-500\") \"of \")\n (span (~cssx/tw :tokens \"text-violet-600 text-4xl\") \"sx\")))") + ((source (first (sx-parse "(div (~cssx/tw :tokens \"text-center\")\n (h1 (~cssx/tw :tokens \"text-3xl font-bold mb-2\")\n (span (~cssx/tw :tokens \"text-rose-500\") \"the \")\n (span (~cssx/tw :tokens \"text-amber-500\") \"joy \")\n (span (~cssx/tw :tokens \"text-emerald-500\") \"of \")\n (span (~cssx/tw :tokens \"text-violet-600 text-4xl\") \"sx\")))"))) + (store + (if (client?) (def-store "home-stepper" (fn () {:step-idx (signal 9)})) nil)) (steps (signal (list))) - (step-idx (signal 9)) + (step-idx (if store (get store "step-idx") (signal 9))) (dom-stack-sig (signal (list))) (code-tokens (signal (list)))) (letrec @@ -264,17 +266,7 @@ ((target (- (deref step-idx) 1))) (rebuild-preview target) (reset! step-idx target) - (update-code-highlight) - (set-cookie "sx-home-stepper" (freeze-to-sx "home-stepper"))))))) - (freeze-scope "home-stepper" (fn () (freeze-signal "step" step-idx))) - (let - ((saved (get-cookie "sx-home-stepper"))) - (when - saved - (thaw-from-sx saved) - (when - (or (< (deref step-idx) 0) (> (deref step-idx) 16)) - (reset! step-idx 9)))) + (update-code-highlight)))))) (let ((parsed (sx-parse source))) (when @@ -320,10 +312,7 @@ (div :class "flex items-center justify-center gap-2 md:gap-3" (button - :on-click (fn - (e) - (do-back) - (set-cookie "sx-home-stepper" (freeze-to-sx "home-stepper"))) + :on-click (fn (e) (do-back)) :class (str "px-2 py-1 rounded text-3xl " (if @@ -337,10 +326,7 @@ " / " (len (deref steps))) (button - :on-click (fn - (e) - (do-step) - (set-cookie "sx-home-stepper" (freeze-to-sx "home-stepper"))) + :on-click (fn (e) (do-step)) :class (str "px-2 py-1 rounded text-3xl " (if diff --git a/tests/playwright/navigation.spec.js b/tests/playwright/navigation.spec.js index 386a76cb..df074126 100644 --- a/tests/playwright/navigation.spec.js +++ b/tests/playwright/navigation.spec.js @@ -125,6 +125,46 @@ test.describe('Page Navigation', () => { expect(after).toContain('geography'); }); + test('stepper persists index across navigation', async ({ page }) => { + await page.goto(BASE_URL + '/sx/', { waitUntil: 'networkidle' }); + await page.waitForTimeout(3000); + + // Get the initial stepper index + const getIndex = () => page.evaluate(() => { + const el = document.querySelector('[data-sx-island="home/stepper"]'); + const m = el && el.textContent.match(/(\d+)\s*\/\s*\d+/); + return m ? parseInt(m[1]) : null; + }); + + const initial = await getIndex(); + expect(initial).not.toBeNull(); + + // Advance the stepper + await page.evaluate(() => { + const btns = document.querySelectorAll('[data-sx-island="home/stepper"] button'); + if (btns.length >= 2) btns[1].click(); // next button + }); + await page.waitForTimeout(500); + + const advanced = await getIndex(); + expect(advanced).toBe(initial + 1); + + // Navigate away + await page.click('a[sx-get*="(geography)"]'); + await page.waitForTimeout(2000); + + // Navigate back home + await page.evaluate(() => { + const link = document.querySelector('a[sx-get*="/sx/"]'); + if (link) link.click(); + }); + await page.waitForTimeout(3000); + + // Stepper should still show the advanced index + const afterNav = await getIndex(); + expect(afterNav).toBe(advanced); + }); + test('header island renders with SSR', async ({ page }) => { await page.goto(BASE_URL + '/sx/(geography)', { waitUntil: 'networkidle' });