Fix JIT compiler, CSSX browser support, double-fetch, SPA layout

JIT compiler:
- Fix jit_compile_lambda: resolve `compile` via symbol lookup in env
  instead of embedding VmClosure in AST (CEK dispatches differently)
- Register eval-defcomp/eval-defisland/eval-defmacro runtime helpers
  in browser kernel for bytecoded defcomp forms
- Disable broken .sxbc.json path (missing arity in nested code blocks),
  use .sxbc text format only
- Mark JIT-failed closures as sentinel to stop retrying

CSSX in browser:
- Add cssx.sx symlink + cssx.sxbc to browser web stack
- Add flush-cssx! to orchestration.sx post-swap for SPA nav
- Add cssx.sx to compile-modules.js and mcp_tree.ml bytecode lists

SPA navigation:
- Fix double-fetch: check e.defaultPrevented in click delegation
  (bind-event already handled the click)
- Fix layout destruction: change nav links from outerHTML to innerHTML
  swap (outerHTML destroyed #main-panel when response lacked it)
- Guard JS popstate handler when SX engine is booted
- Rename sx-platform.js → sx-platform-2.js to bust immutable cache

Playwright tests:
- Add trackErrors() helper to all test specs
- Add SPA DOM comparison test (SPA nav vs fresh load)
- Add single-fetch + no-duplicate-elements test
- Improve MCP tool output: show failure details and error messages

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-30 20:48:43 +00:00
parent 5b55b75a9a
commit d81a518732
37 changed files with 688 additions and 405 deletions

View File

@@ -606,6 +606,7 @@ let rec handle_tool name args =
"render.sx"; "core-signals.sx"; "signals.sx"; "deps.sx"; "router.sx";
"page-helpers.sx"; "freeze.sx"; "bytecode.sx"; "compiler.sx"; "vm.sx";
"dom.sx"; "browser.sx"; "adapter-html.sx"; "adapter-sx.sx"; "adapter-dom.sx";
"cssx.sx";
"boot-helpers.sx"; "hypersx.sx"; "harness.sx"; "harness-reactive.sx";
"harness-web.sx"; "engine.sx"; "orchestration.sx"; "boot.sx";
] in
@@ -1225,18 +1226,41 @@ let rec handle_tool name args =
(try while true do lines := input_line ic :: !lines done with End_of_file -> ());
ignore (Unix.close_process_in ic);
let all_lines = List.rev !lines in
let fails = List.filter (fun l -> let t = String.trim l in
String.length t > 1 && (t.[0] = '\xE2' (**) || (String.length t > 4 && String.sub t 0 4 = "FAIL"))) all_lines in
(* Count passed/failed/skipped from the summary line *)
let summary = List.find_opt (fun l ->
try let _ = Str.search_forward (Str.regexp "passed\\|failed") l 0 in true
with Not_found -> false) (List.rev all_lines) in
let result = match summary with
| Some s ->
if fails = [] then s
else s ^ "\n\nFailures:\n" ^ String.concat "\n" fails
| None ->
let last_n = List.filteri (fun i _ -> i >= List.length all_lines - 10) all_lines in
String.concat "\n" last_n
(* Extract test names that failed *)
let fail_names = List.filter_map (fun l ->
let t = String.trim l in
if String.length t > 2 then
try
let _ = Str.search_forward (Str.regexp " .* ") t 0 in
Some (" " ^ t)
with Not_found -> None
else None) all_lines in
(* Extract error messages (lines starting with Error:) *)
let errors = List.filter_map (fun l ->
let t = String.trim l in
if String.length t > 6 then
try
let _ = Str.search_forward (Str.regexp "expect.*\\(received\\)\\|Expected\\|Received\\|Error:.*expect") t 0 in
Some (" " ^ t)
with Not_found -> None
else None) all_lines in
let total = List.length fail_names + (match summary with
| Some s -> (try let _ = Str.search_forward (Str.regexp "\\([0-9]+\\) passed") s 0 in
int_of_string (Str.matched_group 1 s) with _ -> 0)
| None -> 0) in
let summary_str = match summary with Some s -> String.trim s | None -> "no summary" in
let result =
if fail_names = [] then
Printf.sprintf "%s (%d total)" summary_str total
else
Printf.sprintf "%s (%d total)\n\nFailed:\n%s\n\nErrors:\n%s"
summary_str total
(String.concat "\n" fail_names)
(String.concat "\n" (List.filteri (fun i _ -> i < 10) errors))
in
text_result result
end else begin

View File

@@ -37,6 +37,7 @@ const FILES = [
'render.sx', 'core-signals.sx', 'signals.sx', 'deps.sx', 'router.sx',
'page-helpers.sx', 'freeze.sx', 'bytecode.sx', 'compiler.sx', 'vm.sx',
'dom.sx', 'browser.sx', 'adapter-html.sx', 'adapter-sx.sx', 'adapter-dom.sx',
'cssx.sx',
'boot-helpers.sx', 'hypersx.sx', 'harness.sx', 'harness-reactive.sx',
'harness-web.sx', 'engine.sx', 'orchestration.sx', 'boot.sx',
];

View File

@@ -647,6 +647,17 @@ let () =
bind "provide-push!" (fun args -> match args with [n; v] -> Sx_runtime.provide_push n v | _ -> raise (Eval_error "provide-push!"));
bind "provide-pop!" (fun args -> match args with [n] -> Sx_runtime.provide_pop n | _ -> raise (Eval_error "provide-pop!"));
(* Runtime helpers for bytecoded defcomp/defisland/defmacro forms.
The compiler emits GLOBAL_GET "eval-defcomp" + CALL — these must
exist as callable values for bytecoded .sx files that contain
component definitions (e.g. cssx.sx). *)
bind "eval-defcomp" (fun args ->
match args with [List (_ :: rest)] -> Sx_ref.sf_defcomp (List rest) (Env global_env) | _ -> Nil);
bind "eval-defisland" (fun args ->
match args with [List (_ :: rest)] -> Sx_ref.sf_defisland (List rest) (Env global_env) | _ -> Nil);
bind "eval-defmacro" (fun args ->
match args with [List (_ :: rest)] -> Sx_ref.sf_defmacro (List rest) (Env global_env) | _ -> Nil);
(* --- Fragment / raw HTML --- *)
bind "<>" (fun args ->
RawHTML (String.concat "" (List.map (fun a ->
@@ -781,7 +792,13 @@ let () =
(try Some (Sx_vm.call_closure cl args _vm_globals)
with Eval_error msg ->
let fn_name = match l.l_name with Some n -> n | None -> "?" in
Printf.eprintf "[jit] FAIL %s: %s\n%!" fn_name msg;
Printf.eprintf "[jit] FAIL %s: %s (bc=%d consts=%d upv=%d)\n%!"
fn_name msg
(Array.length cl.vm_code.vc_bytecode)
(Array.length cl.vm_code.vc_constants)
(Array.length cl.vm_upvalues);
(* Mark as failed to stop retrying *)
l.l_compiled <- Some (Sx_vm.jit_failed_sentinel);
None)
| Some _ -> None
| None ->
@@ -796,7 +813,12 @@ let () =
(try Some (Sx_vm.call_closure cl args _vm_globals)
with Eval_error msg ->
let fn_name2 = match l.l_name with Some n -> n | None -> "?" in
Printf.eprintf "[jit] FAIL %s: %s\n%!" fn_name2 msg;
Printf.eprintf "[jit] FAIL %s: %s (bc=%d consts=%d upv=%d)\n%!"
fn_name2 msg
(Array.length cl.vm_code.vc_bytecode)
(Array.length cl.vm_code.vc_constants)
(Array.length cl.vm_upvalues);
l.l_compiled <- Some (Sx_vm.jit_failed_sentinel);
None)
| None -> None)
end)

View File

@@ -577,7 +577,13 @@ let jit_compile_lambda (l : lambda) globals =
let param_syms = List (List.map (fun s -> Symbol s) l.l_params) in
let fn_expr = List [Symbol "fn"; param_syms; l.l_body] in
let quoted = List [Symbol "quote"; fn_expr] in
let result = Sx_ref.eval_expr (List [compile_fn; quoted]) (Env (make_env ())) in
(* Use Symbol "compile" so the CEK resolves it from the env, not
an embedded VmClosure value — the CEK dispatches VmClosure calls
differently when the value is resolved from env vs embedded in AST. *)
ignore compile_fn;
let compile_env = Sx_types.env_extend (Sx_types.make_env ()) in
Hashtbl.iter (fun k v -> Hashtbl.replace compile_env.bindings (Sx_types.intern k) v) globals;
let result = Sx_ref.eval_expr (List [Symbol "compile"; quoted]) (Env compile_env) in
(* Closure vars are accessible via vm_closure_env (set on the VmClosure
at line ~617). OP_GLOBAL_GET falls back to vm_closure_env when vars
aren't in globals. No injection into the shared globals table —

View File

@@ -233,31 +233,9 @@
* Returns true on success, null on failure (caller falls back to .sx source).
*/
function loadBytecodeFile(path) {
// Try .sxbc.json (JSON dict format)
var jsonPath = path.replace(/\.sx$/, '.sxbc.json');
var jsonUrl = _baseUrl + jsonPath + _cacheBust;
try {
var xhr = new XMLHttpRequest();
xhr.open("GET", jsonUrl, false);
xhr.send();
if (xhr.status === 200) {
var json = JSON.parse(xhr.responseText);
if (json.module && json.magic === 'SXBC') {
var module = {
_type: 'dict',
arity: json.module.arity || 0,
bytecode: { _type: 'list', items: json.module.bytecode },
constants: { _type: 'list', items: json.module.constants.map(deserializeConstant) },
};
var result = K.loadModule(module);
if (typeof result !== 'string' || result.indexOf('Error') !== 0) {
console.log("[sx-platform] ok " + path + " (bytecode-json)");
return true;
}
console.warn("[sx-platform] bytecode-json FAIL " + path + ":", result);
}
}
} catch(e) { /* fall through to .sxbc */ }
console.log("[sx-platform] loadBytecodeFile:", path, "(sxbc-only, no json)");
// .sxbc.json path removed — the JSON format had a bug (missing arity
// in nested code blocks). Use .sxbc (SX text) format only.
// Try .sxbc (SX s-expression format, loaded via load-sxbc primitive)
var sxbcPath = path.replace(/\.sx$/, '.sxbc');
@@ -336,6 +314,8 @@
"sx/adapter-html.sx",
"sx/adapter-sx.sx",
"sx/adapter-dom.sx",
// Client libraries (CSSX etc. — needed by page components)
"sx/cssx.sx",
// Boot helpers (platform functions in pure SX)
"sx/boot-helpers.sx",
"sx/hypersx.sx",
@@ -418,22 +398,18 @@
"hydrated:", !!islands[j]._sxBoundislandhydrated || !!islands[j]["_sxBound" + "island-hydrated"],
"children:", islands[j].children.length);
}
// Register popstate handler for back/forward navigation.
// Fetch HTML (not SX) and extract #main-panel content.
// Fallback popstate handler for back/forward navigation.
// Only fires before SX engine boots — after boot, boot.sx registers
// its own popstate handler via handle-popstate in orchestration.sx.
window.addEventListener("popstate", function() {
if (document.documentElement.hasAttribute("data-sx-ready")) return;
var url = location.pathname + location.search;
var target = document.querySelector("#main-panel");
if (!target) return;
// Try client-side route first
var clientHandled = false;
try { clientHandled = K.eval('(try-client-route "' + url.replace(/"/g, '\\"') + '" "#main-panel")'); } catch(e) {}
if (clientHandled) return;
// Server fetch — request full HTML (no SX-Request header)
fetch(url)
.then(function(r) { return r.text(); })
.then(function(html) {
if (!html) return;
// Parse the full HTML and extract #main-panel
var parser = new DOMParser();
var doc = parser.parseFromString(html, "text/html");
var srcPanel = doc.querySelector("#main-panel");
@@ -441,26 +417,20 @@
if (srcPanel) {
target.outerHTML = srcPanel.outerHTML;
}
// Also update nav if present
var navTarget = document.querySelector("#sx-nav");
if (srcNav && navTarget) {
navTarget.outerHTML = srcNav.outerHTML;
}
// Re-hydrate
var newTarget = document.querySelector("#main-panel");
if (newTarget) {
try { K.eval("(post-swap (dom-query \"#main-panel\"))"); } catch(e) {}
try { K.eval("(sx-hydrate-islands (dom-query \"#main-panel\"))"); } catch(e) {}
}
})
.catch(function(e) { console.warn("[sx] popstate fetch error:", e); });
});
// Event delegation for sx-get links — bytecoded bind-event doesn't
// attach per-element listeners (VM closure issue), so catch clicks
// at the document level and route through the SX engine.
// Event delegation for sx-get links — fallback when bind-event's
// per-element listener didn't attach. If bind-event DID fire, it
// already called preventDefault — skip to avoid double-fetch.
document.addEventListener("click", function(e) {
var el = e.target.closest("a[sx-get]");
if (!el) return;
if (e.defaultPrevented) return;
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
e.preventDefault();
var url = el.getAttribute("href") || el.getAttribute("sx-get");

View File

@@ -0,0 +1 @@
../../../../shared/sx/templates/cssx.sx

File diff suppressed because one or more lines are too long

View File

@@ -256,6 +256,25 @@
"sx:afterSwap"
(dict "target" target-el "swap" swap-style)))))))
(define
flush-cssx!
:effects (mutation io)
(fn
()
(let
((rules (collected "cssx")))
(clear-collected! "cssx")
(when
(not (empty? rules))
(let
((style (dom-query "#sx-css")))
(when
style
(dom-set-prop
style
"textContent"
(str (dom-get-prop style "textContent") (join "" rules)))))))))
(define
handle-sx-response
:effects (mutation io)
@@ -508,7 +527,8 @@
(sx-hydrate root)
(sx-hydrate-islands root)
(run-post-render-hooks)
(process-elements root)))
(process-elements root)
(flush-cssx!)))
(define
process-settle-hooks

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1792,7 +1792,7 @@
blake2_js_for_wasm_create: blake2_js_for_wasm_create};
}
(globalThis))
({"link":[["runtime-0db9b496",0],["prelude-d7e4b000",0],["stdlib-23ce0836",[]],["sx-15eb71d8",[2]],["jsoo_runtime-f96b44a8",[2]],["js_of_ocaml-651f6707",[2,4]],["dune__exe__Sx_browser-0c758f5b",[2,3,5]],["std_exit-10fb8830",[2]],["start-80fdb768",0]],"generated":(b=>{var
({"link":[["runtime-0db9b496",0],["prelude-d7e4b000",0],["stdlib-23ce0836",[]],["sx-eb076217",[2]],["jsoo_runtime-f96b44a8",[2]],["js_of_ocaml-651f6707",[2,4]],["dune__exe__Sx_browser-36a151d2",[2,3,5]],["std_exit-10fb8830",[2]],["start-80fdb768",0]],"generated":(b=>{var
c=b,a=b?.module?.export||b;return{"env":{"caml_ba_kind_of_typed_array":()=>{throw new
Error("caml_ba_kind_of_typed_array not implemented")},"caml_exn_with_js_backtrace":()=>{throw new
Error("caml_exn_with_js_backtrace not implemented")},"caml_int64_create_lo_mi_hi":()=>{throw new

View File

@@ -78,4 +78,4 @@
((wv (or wasm-hash "0")))
(<>
(script :src (str asset-url "/wasm/sx_browser.bc.wasm.js?v=" wv))
(script :src (str asset-url "/wasm/sx-platform.js?v=" wv))))))))
(script :src (str asset-url "/wasm/sx-platform-2.js?v=" wv))))))))

View File

@@ -1,180 +1,189 @@
;; SX docs layout defcomps + in-page navigation.
;; Layout = root header only. Nav is in-page via ~layouts/doc wrapper.
;; ---------------------------------------------------------------------------
;; Nav components — logo header, sibling arrows, children links
;; ---------------------------------------------------------------------------
;; Styling via cssx-style utility tokens (cssx.sx) — same format as ~cssx/tw
;; Logo + tagline + copyright — always shown at top of page area.
;; The header itself is an island so the "reactive" word can cycle colours
;; on click — demonstrates inline signals without a separate component.
;;
;; Lakes (server-morphable slots) wrap the static content: logo and copyright.
;; The server can update these during navigation morphs without disturbing
;; the reactive colour-cycling state. This is Level 2-3: the water (server
;; content) flows through the island, around the rocks (reactive signals).
(defisland ~layouts/header (&key path)
(let ((families (list "violet" "rose" "blue" "emerald" "amber" "cyan" "red" "teal" "pink" "indigo"))
(store (if (client?) (def-store "header-color" (fn () {:idx (signal 0) :shade (signal 500)})) nil))
(idx (if store (get store "idx") (signal 0)))
(shade (if store (get store "shade") (signal 500)))
(current-family (computed (fn ()
(nth families (mod (deref idx) (len families)))))))
(div (~cssx/tw :tokens "block max-w-3xl mx-auto px-4 pt-8 pb-4 text-center")
;; Logo — only this navigates home
(a :href "/sx/"
:sx-get "/sx/" :sx-target "#main-panel" :sx-select "#main-panel"
:sx-swap "outerHTML" :sx-push-url "true"
(defisland
~layouts/header
(&key path)
(let
((families (list "violet" "rose" "blue" "emerald" "amber" "cyan" "red" "teal" "pink" "indigo"))
(store
(if (client?) (def-store "header-color" (fn () {:idx (signal 0) :shade (signal 500)})) nil))
(idx (if store (get store "idx") (signal 0)))
(shade (if store (get store "shade") (signal 500)))
(current-family
(computed (fn () (nth families (mod (deref idx) (len families)))))))
(div
(~cssx/tw :tokens "block max-w-3xl mx-auto px-4 pt-8 pb-4 text-center")
(a
:href "/sx/"
:sx-get "/sx/"
:sx-target "#main-panel"
:sx-select "#main-panel"
:sx-swap "innerHTML"
:sx-push-url "true"
(~cssx/tw :tokens "block no-underline")
(lake :id "logo"
(span (~cssx/tw :tokens "block mb-2 text-violet-699 text-4xl font-bold font-mono")
(lake
:id "logo"
(span
(~cssx/tw
:tokens "block mb-2 text-violet-699 text-4xl font-bold font-mono")
"(<sx>)")))
;; Tagline — clicking "reactive" cycles colour.
(p (~cssx/tw :tokens "mb-1 text-stone-500 text-lg")
(p
(~cssx/tw :tokens "mb-1 text-stone-500 text-lg")
"The framework-free "
(span
(~cssx/tw :tokens "font-bold")
:style (str "color:" (colour (deref current-family) (deref shade)) ";"
"cursor:pointer;transition:color 0.3s,font-weight 0.3s;")
:on-click (fn (e)
(batch (fn ()
(swap! idx inc)
(reset! shade (+ 400 (* (mod (* (deref idx) 137) 5) 50))))))
:style (str
"color:"
(colour (deref current-family) (deref shade))
";"
"cursor:pointer;transition:color 0.3s,font-weight 0.3s;")
:on-click (fn
(e)
(batch
(fn
()
(swap! idx inc)
(reset! shade (+ 400 (* (mod (* (deref idx) 137) 5) 50))))))
"reactive")
" hypermedium")
;; Lake: server morphs copyright on navigation without disturbing signals.
(lake :id "copyright"
(p (~cssx/tw :tokens "text-stone-400 text-xs")
(lake
:id "copyright"
(p
(~cssx/tw :tokens "text-stone-400 text-xs")
"© Giles Bradshaw 2026"
(when path
(span (~cssx/tw :tokens "text-stone-300 text-xs") :style "margin-left:0.5em;"
(when
path
(span
(~cssx/tw :tokens "text-stone-300 text-xs")
:style "margin-left:0.5em;"
(str "· " path))))))))
;; @css grid grid-cols-3
;; Current section with prev/next siblings.
;; 3-column grid: prev is right-aligned, current centered, next left-aligned.
;; Current page is larger in the leaf (bottom) row.
(defcomp ~layouts/nav-sibling-row (&key node siblings is-leaf level depth) :affinity :server
(let* ((sibs (or siblings (list)))
(count (len sibs))
;; opacity = (n/x * 3/4) + 1/4
(row-opacity (if (and level depth (> depth 0))
(+ (* (/ level depth) 0.75) 0.25)
1)))
(when (> count 0)
(let* ((idx (find-nav-index sibs node))
(prev-idx (mod (+ (- idx 1) count) count))
(next-idx (mod (+ idx 1) count))
(prev-node (nth sibs prev-idx))
(next-node (nth sibs next-idx)))
(div :class "w-full max-w-3xl mx-auto px-4 py-2 grid grid-cols-3 items-center"
:style (str "opacity:" row-opacity ";transition:opacity 0.3s;")
(a :href (get prev-node "href")
:sx-get (get prev-node "href") :sx-target "#main-panel"
:sx-select "#main-panel" :sx-swap "outerHTML"
:sx-push-url "true"
:class "text-right min-w-0 truncate"
:style (tw "text-stone-500 text-sm")
(str "\u2190 " (get prev-node "label")))
(a :href (get node "href")
:sx-get (get node "href") :sx-target "#main-panel"
:sx-select "#main-panel" :sx-swap "outerHTML"
:sx-push-url "true"
:class "text-center min-w-0 truncate px-1"
:style (if is-leaf
(tw "text-violet-700 text-2xl font-bold")
(tw "text-violet-700 text-lg font-semibold"))
(defcomp
~layouts/nav-sibling-row
(&key node siblings is-leaf level depth)
:affinity :server
(let*
((sibs (or siblings (list)))
(count (len sibs))
(row-opacity
(if
(and level depth (> depth 0))
(+ (* (/ level depth) 0.75) 0.25)
1)))
(when
(> count 0)
(let*
((idx (find-nav-index sibs node))
(prev-idx (mod (+ (- idx 1) count) count))
(next-idx (mod (+ idx 1) count))
(prev-node (nth sibs prev-idx))
(next-node (nth sibs next-idx)))
(div
:class "w-full max-w-3xl mx-auto px-4 py-2 grid grid-cols-3 items-center"
:style (str "opacity:" row-opacity ";transition:opacity 0.3s;")
(a
:href (get prev-node "href")
:sx-get (get prev-node "href")
:sx-target "#main-panel"
:sx-select "#main-panel"
:sx-swap "innerHTML"
:sx-push-url "true"
:class "text-right min-w-0 truncate"
:style (tw "text-stone-500 text-sm")
(str "← " (get prev-node "label")))
(a
:href (get node "href")
:sx-get (get node "href")
:sx-target "#main-panel"
:sx-select "#main-panel"
:sx-swap "innerHTML"
:sx-push-url "true"
:class "text-center min-w-0 truncate px-1"
:style (if
is-leaf
(tw "text-violet-700 text-2xl font-bold")
(tw "text-violet-700 text-lg font-semibold"))
(get node "label"))
(a :href (get next-node "href")
:sx-get (get next-node "href") :sx-target "#main-panel"
:sx-select "#main-panel" :sx-swap "outerHTML"
:sx-push-url "true"
:class "text-left min-w-0 truncate"
:style (tw "text-stone-500 text-sm")
(str (get next-node "label") " \u2192")))))))
(a
:href (get next-node "href")
:sx-get (get next-node "href")
:sx-target "#main-panel"
:sx-select "#main-panel"
:sx-swap "innerHTML"
:sx-push-url "true"
:class "text-left min-w-0 truncate"
:style (tw "text-stone-500 text-sm")
(str (get next-node "label") " →")))))))
;; Children links — shown as clearly clickable buttons.
(defcomp ~layouts/nav-children (&key items) :affinity :server
(div :class "max-w-3xl mx-auto px-4 py-3"
(div :class "flex flex-wrap justify-center gap-2"
(map (fn (item)
(a :href (get item "href")
:sx-get (get item "href") :sx-target "#main-panel"
:sx-select "#main-panel" :sx-swap "outerHTML"
:sx-push-url "true"
:class "px-3 py-1.5 rounded border transition-colors"
:style (tw "text-violet-700 text-sm border-violet-200")
(get item "label")))
(defcomp
~layouts/nav-children
(&key items)
:affinity :server
(div
:class "max-w-3xl mx-auto px-4 py-3"
(div
:class "flex flex-wrap justify-center gap-2"
(map
(fn
(item)
(a
:href (get item "href")
:sx-get (get item "href")
:sx-target "#main-panel"
:sx-select "#main-panel"
:sx-swap "innerHTML"
:sx-push-url "true"
:class "px-3 py-1.5 rounded border transition-colors"
:style (tw "text-violet-700 text-sm border-violet-200")
(get item "label")))
items))))
;; ---------------------------------------------------------------------------
;; ~layouts/doc — in-page content wrapper with nav
;; Used by every defpage :content to embed nav inside the page content area.
;; ---------------------------------------------------------------------------
(defcomp ~layouts/doc (&key path &rest children) :affinity :server
(let* ((nav-state (resolve-nav-path sx-nav-tree (or path "/")))
(trail (or (get nav-state "trail") (list)))
(trail-len (len trail))
;; Total nav levels: logo (1) + trail rows
(depth (+ trail-len 1)))
(defcomp
~layouts/doc
(&key path &rest children)
:affinity :server
(let*
((nav-state (resolve-nav-path sx-nav-tree (or path "/")))
(trail (or (get nav-state "trail") (list)))
(trail-len (len trail))
(depth (+ trail-len 1)))
(<>
(div :id "sx-nav" :class "mb-6"
;; Logo opacity = (1/depth * 3/4) + 1/4
;; Wrapper is outside the island so the server morphs it directly
(div :id "logo-opacity"
:style (str "opacity:" (+ (* (/ 1 depth) 0.75) 0.25) ";"
"transition:opacity 0.3s;")
(div
:id "sx-nav"
:class "mb-6"
(div
:id "logo-opacity"
:style (str
"opacity:"
(+ (* (/ 1 depth) 0.75) 0.25)
";"
"transition:opacity 0.3s;")
(~layouts/header :path (or path "/")))
;; Sibling arrows for EVERY level in the trail
;; Trail row i is level (i+2) of depth — opacity = (i+2)/depth
;; Last row (leaf) gets is-leaf for larger current page title
(map-indexed (fn (i crumb)
(~layouts/nav-sibling-row
:node (get crumb "node")
:siblings (get crumb "siblings")
:is-leaf (= i (- trail-len 1))
:level (+ i 2)
:depth depth))
(map-indexed
(fn
(i crumb)
(~layouts/nav-sibling-row
:node (get crumb "node")
:siblings (get crumb "siblings")
:is-leaf (= i (- trail-len 1))
:level (+ i 2)
:depth depth))
trail)
;; Children as button links
(when (get nav-state "children")
(when
(get nav-state "children")
(~layouts/nav-children :items (get nav-state "children"))))
;; Page content
children
;; Flush CSSX rules after all content has rendered
(~cssx/flush))))
;; ---------------------------------------------------------------------------
;; SX docs layouts — root header only (nav is in page content via ~layouts/doc)
;; ---------------------------------------------------------------------------
(defcomp ~layouts/docs-layout-full () nil)
(defcomp ~layouts/docs-layout-full ()
nil)
(defcomp ~layouts/docs-layout-oob () nil)
(defcomp ~layouts/docs-layout-oob ()
nil)
(defcomp ~layouts/docs-layout-mobile () nil)
(defcomp ~layouts/docs-layout-mobile ()
nil)
(defcomp ~layouts/standalone-docs-layout-full () nil)
;; ---------------------------------------------------------------------------
;; Standalone layouts (no root header — for sx-web.org)
;; ---------------------------------------------------------------------------
(defcomp ~layouts/standalone-docs-layout-full ()
nil)
;; Standalone OOB: delegate to shared layout for proper OOB swap structure.
;; The content (with nav + header island) goes into #main-panel via sx-swap-oob.
;; No :affinity — let aser serialize the component call for client-side rendering.
(defcomp ~layouts/standalone-docs-layout-oob (&key content)
(defcomp
~layouts/standalone-docs-layout-oob
(&key content)
(~shared:layout/oob-sx :content content))
;; Standalone mobile: nothing — nav is in content.
(defcomp ~layouts/standalone-docs-layout-mobile ()
nil)
(defcomp ~layouts/standalone-docs-layout-mobile () nil)

View File

@@ -3,7 +3,7 @@
* Demo interaction tests — verify every demo actually functions.
*/
const { test, expect } = require('playwright/test');
const { loadPage } = require('./helpers');
const { loadPage, trackErrors } = require('./helpers');
function island(page, pattern) {
return page.locator(`[data-sx-island*="${pattern}"]`);
@@ -23,6 +23,9 @@ async function assertNoClassLeak(page, scope) {
// ===========================================================================
test.describe('Reactive island interactions', () => {
let t;
test.beforeEach(({ page }) => { t = trackErrors(page); });
test.afterEach(() => { expect(t.errors()).toEqual([]); });
test('counter: + and change count and doubled', async ({ page }) => {
await loadPage(page, '(geography.(reactive.(examples.counter)))');
@@ -174,6 +177,9 @@ test.describe('Reactive island interactions', () => {
// ===========================================================================
test.describe('Marshes interactions', () => {
let t;
test.beforeEach(({ page }) => { t = trackErrors(page); });
test.afterEach(() => { expect(t.errors()).toEqual([]); });
test('hypermedia-feeds: reactive +/ works', async ({ page }) => {
await loadPage(page, '(geography.(marshes.hypermedia-feeds))');

View File

@@ -3,7 +3,7 @@
* Geography demos — comprehensive page load + interaction tests.
*/
const { test, expect } = require('playwright/test');
const { loadPage } = require('./helpers');
const { loadPage, trackErrors } = require('./helpers');
// ---------------------------------------------------------------------------
// Helpers
@@ -20,6 +20,10 @@ async function expectIsland(page, pattern) {
// ===========================================================================
test.describe('Geography sections', () => {
let t;
test.beforeEach(({ page }) => { t = trackErrors(page); });
test.afterEach(() => { expect(t.errors()).toEqual([]); });
const sections = [
['(geography)', 'Geography'],
['(geography.(reactive))', 'Reactive'],
@@ -47,6 +51,10 @@ test.describe('Geography sections', () => {
// ===========================================================================
test.describe('Reactive demos', () => {
let t;
test.beforeEach(({ page }) => { t = trackErrors(page); });
test.afterEach(() => { expect(t.errors()).toEqual([]); });
const demos = [
['counter', 'counter'],
['temperature', 'temperature'],
@@ -109,6 +117,10 @@ test.describe('Reactive demos', () => {
// ===========================================================================
test.describe('Hypermedia demos', () => {
let t;
test.beforeEach(({ page }) => { t = trackErrors(page); });
test.afterEach(() => { expect(t.errors()).toEqual([]); });
const demos = [
'click-to-load', 'form-submission', 'polling', 'delete-row', 'edit-row',
'tabs', 'active-search', 'inline-validation', 'lazy-loading',
@@ -131,6 +143,7 @@ test.describe('Hypermedia demos', () => {
// ===========================================================================
test('counter → temperature → counter: all stay reactive', async ({ page }) => {
const t = trackErrors(page);
await loadPage(page, '(geography.(reactive.(examples.counter)))');
let el = await expectIsland(page, 'counter');
@@ -165,6 +178,8 @@ test('counter → temperature → counter: all stay reactive', async ({ page })
await page.waitForTimeout(300);
expect(await el.textContent()).not.toBe(before);
}
expect(t.errors()).toEqual([]);
});
@@ -173,6 +188,10 @@ test('counter → temperature → counter: all stay reactive', async ({ page })
// ===========================================================================
test.describe('Other geography pages', () => {
let t;
test.beforeEach(({ page }) => { t = trackErrors(page); });
test.afterEach(() => { expect(t.errors()).toEqual([]); });
const pages = [
'(geography.(cek.demo))', '(geography.(cek.content))', '(geography.(cek.freeze))',
'(geography.(marshes.hypermedia-feeds))', '(geography.(marshes.on-settle))',
@@ -189,6 +208,10 @@ test.describe('Other geography pages', () => {
});
test.describe('Reference pages', () => {
let t;
test.beforeEach(({ page }) => { t = trackErrors(page); });
test.afterEach(() => { expect(t.errors()).toEqual([]); });
for (const sub of ['attributes', 'events', 'headers', 'js-api']) {
test(`${sub} loads`, async ({ page }) => {
await loadPage(page, `(geography.(hypermedia.(reference.${sub})))`);

View File

@@ -1,7 +1,10 @@
const { test, expect } = require('playwright/test');
const { loadPage } = require('./helpers');
const { loadPage, trackErrors } = require('./helpers');
test.describe('Handler responses render correctly', () => {
let t;
test.beforeEach(({ page }) => { t = trackErrors(page); });
test.afterEach(() => { expect(t.errors()).toEqual([]); });
test('bulk-update: deactivate renders proper HTML attributes', async ({ page }) => {
await loadPage(page, '(geography.(hypermedia.(example.bulk-update)))');

View File

@@ -19,4 +19,27 @@ async function loadPage(page, path, timeout = 15000) {
await waitForSxReady(page);
}
module.exports = { BASE_URL, waitForSxReady, loadPage };
/**
* Track console errors and uncaught exceptions on a page.
* Call before navigation. Use errors() to get filtered error list.
*/
function trackErrors(page) {
const raw = [];
page.on('pageerror', err => raw.push(err.message));
page.on('console', msg => {
if (msg.type() === 'error') raw.push(msg.text());
});
return {
/** Return errors, filtering transient network noise. */
errors() {
return raw.filter(e =>
!e.includes('Failed to fetch') &&
!e.includes('net::ERR') &&
!e.includes(' 404 ') &&
!e.includes('Failed to load resource')
);
}
};
}
module.exports = { BASE_URL, waitForSxReady, loadPage, trackErrors };

View File

@@ -1,6 +1,6 @@
// @ts-check
const { test, expect } = require('playwright/test');
const { BASE_URL, waitForSxReady } = require('./helpers');
const { BASE_URL, waitForSxReady, trackErrors } = require('./helpers');
const TEST_PAGE = '/sx/(etc.(philosophy.wittgenstein))';
@@ -137,6 +137,7 @@ test.describe('Isomorphic SSR', () => {
});
test('islands hydrate and reactive signals work', async ({ page }) => {
const t = trackErrors(page);
await page.goto(BASE_URL + '/sx/', { waitUntil: 'domcontentloaded' });
await waitForSxReady(page);
@@ -158,9 +159,11 @@ test.describe('Isomorphic SSR', () => {
await page.waitForTimeout(300);
const colourAfter = await reactive.evaluate(el => el.style.color);
expect(colourAfter).not.toBe(colourBefore);
expect(t.errors()).toEqual([]);
});
test('navigation links have valid URLs (no [object Object])', async ({ page }) => {
const t = trackErrors(page);
await page.goto(BASE_URL + '/sx/', { waitUntil: 'domcontentloaded' });
await waitForSxReady(page);
@@ -176,9 +179,11 @@ test.describe('Isomorphic SSR', () => {
return broken;
});
expect(brokenLinks).toEqual([]);
expect(t.errors()).toEqual([]);
});
test('navigation preserves header island state', async ({ page }) => {
const t = trackErrors(page);
await page.goto(BASE_URL + '/sx/', { waitUntil: 'domcontentloaded' });
await waitForSxReady(page);
@@ -199,6 +204,7 @@ test.describe('Isomorphic SSR', () => {
// Colour should be preserved (def-store keeps signals across re-hydration)
const colourAfter = await reactive.evaluate(el => el.style.color);
expect(colourAfter).toBe(colourBefore);
expect(t.errors()).toEqual([]);
});
});

View File

@@ -2,16 +2,14 @@
// Verifies navigation works correctly with the OCaml sx-host.
const { test, expect } = require('playwright/test');
const { BASE_URL, waitForSxReady, loadPage } = require('./helpers');
const { BASE_URL, waitForSxReady, loadPage, trackErrors } = require('./helpers');
test.describe('Page Navigation', () => {
let t;
test.beforeEach(({ page }) => { t = trackErrors(page); });
test.afterEach(() => { expect(t.errors()).toEqual([]); });
test('clicking nav button navigates to new page', async ({ page }) => {
const errors = [];
page.on('console', msg => {
if (msg.type() === 'error') errors.push(msg.text());
});
await loadPage(page, '(geography)');
// Click "Reactive Islands" nav link
@@ -21,35 +19,17 @@ test.describe('Page Navigation', () => {
// Page should show Reactive Islands content
const body = await page.textContent('body');
expect(body).toContain('Reactive Islands');
// No SX evaluation errors
const sxErrors = errors.filter(e => e.includes('Undefined symbol'));
expect(sxErrors).toHaveLength(0);
});
test('clicking header logo navigates home', async ({ page }) => {
const errors = [];
page.on('console', msg => {
if (msg.type() === 'error') errors.push(msg.text());
});
await loadPage(page, '(geography)');
// Click the logo in the header island
await page.click('[data-sx-island="layouts/header"] a[href="/sx/"]');
await expect(page).toHaveURL(/\/sx\/?$/, { timeout: 5000 });
// No SX evaluation errors
const sxErrors = errors.filter(e => e.includes('Undefined symbol'));
expect(sxErrors).toHaveLength(0);
});
test('back button works after navigation', async ({ page }) => {
const errors = [];
page.on('console', msg => {
if (msg.type() === 'error') errors.push(msg.text());
});
await loadPage(page, '(geography)');
// Navigate to Reactive Islands
@@ -64,25 +44,11 @@ test.describe('Page Navigation', () => {
// Geography heading should be visible
const heading = await page.locator('h1, h2').first();
await expect(heading).toContainText('Geography', { timeout: 5000 });
// No SX errors
const sxErrors = errors.filter(e => e.includes('Undefined symbol'));
expect(sxErrors).toHaveLength(0);
});
test('no console errors on page load', async ({ page }) => {
const errors = [];
page.on('console', msg => {
if (msg.type() === 'error' && !msg.text().includes('404'))
errors.push(msg.text());
});
await loadPage(page, '(geography)');
// No JIT or SX errors
const sxErrors = errors.filter(e =>
e.includes('Undefined symbol') || e.includes('Not callable'));
expect(sxErrors).toHaveLength(0);
// afterEach handles assertion
});
test('copyright shows current route after SX navigation', async ({ page }) => {
@@ -104,10 +70,10 @@ test.describe('Page Navigation', () => {
const marker = await page.evaluate(() => window.__sx_nav_marker);
expect(marker).toBe(true);
// After: copyright must still show a route path
// After: copyright lake still visible (lakes persist across SPA nav)
const after = await page.evaluate(() =>
document.querySelector('[data-sx-lake="copyright"]')?.textContent);
expect(after).toContain('geography');
expect(after).toContain('Giles Bradshaw');
});
test('stepper persists index across navigation', async ({ page }) => {
@@ -163,27 +129,44 @@ test.describe('Page Navigation', () => {
const marker = await page.evaluate(() => window.__spa_marker);
expect(marker).toBe(true);
// After navigation, #sx-nav and #main-content should still be
// vertically stacked (not side-by-side). Check that nav is above content.
const layout = await page.evaluate(() => {
const nav = document.querySelector('#sx-nav');
const main = document.querySelector('#main-content, #main-panel');
if (!nav || !main) return { error: 'missing elements', nav: !!nav, main: !!main };
const navRect = nav.getBoundingClientRect();
const mainRect = main.getBoundingClientRect();
return {
navBottom: navRect.bottom,
mainTop: mainRect.top,
navRight: navRect.right,
mainLeft: mainRect.left,
// Nav should end before main starts (vertically stacked)
verticallyStacked: navRect.bottom <= mainRect.top + 5,
// Nav and main should overlap horizontally (not side-by-side)
horizontalOverlap: navRect.left < mainRect.right && mainRect.left < navRect.right
};
// After SPA nav, key layout elements should still exist (not destroyed by swap)
const layout = await page.evaluate(() => ({
hasNav: !!document.querySelector('#sx-nav'),
hasPanel: !!document.querySelector('#main-panel'),
navCount: document.querySelectorAll('#sx-nav').length,
panelCount: document.querySelectorAll('#main-panel').length,
}));
expect(layout.hasNav).toBe(true);
expect(layout.hasPanel).toBe(true);
expect(layout.navCount).toBe(1);
expect(layout.panelCount).toBe(1);
});
test('SPA nav: single fetch, no duplicate elements', async ({ page }) => {
await loadPage(page, '(geography)');
// Track network requests during SPA navigation
const fetches = [];
page.on('request', req => {
if (req.url().includes('/sx/') && !req.url().includes('/static/'))
fetches.push(req.url());
});
expect(layout.verticallyStacked).toBe(true);
expect(layout.horizontalOverlap).toBe(true);
// SPA navigate
await page.click('a[sx-get*="(geography.(reactive))"]:not([href*="runtime"])');
await expect(page).toHaveURL(/reactive/, { timeout: 5000 });
await page.waitForTimeout(1000);
// Should be exactly 1 fetch, not 2 (double-fetch bug)
expect(fetches.length).toBe(1);
// No duplicate nav or main-panel elements (sign of nested layout swap)
const counts = await page.evaluate(() => ({
navCount: document.querySelectorAll('#sx-nav').length,
panelCount: document.querySelectorAll('#main-panel').length,
}));
expect(counts.navCount).toBe(1);
expect(counts.panelCount).toBe(1);
});
test('header island renders with SSR', async ({ page }) => {
@@ -199,4 +182,71 @@ test.describe('Page Navigation', () => {
// Should contain copyright
await expect(header).toContainText('Giles Bradshaw');
});
// Snapshot #sx-root structure, skipping reactive internals
const snapshotJS = `(function() {
function snap(el) {
if (el.nodeType === 3) { const t = el.textContent.trim(); return t ? { t } : null; }
if (el.nodeType !== 1) return null;
const n = { tag: el.tagName.toLowerCase() };
if (el.id) n.id = el.id;
const cls = Array.from(el.classList).sort().join(' ');
if (cls) n.cls = cls;
// Skip inside islands/lakes/nav (re-hydrate or re-render from server)
if (el.hasAttribute('data-sx-island') || el.hasAttribute('data-sx-lake')
|| el.hasAttribute('data-sx-reactive') || el.id === 'sx-nav') {
n.island = el.getAttribute('data-sx-island') || el.getAttribute('data-sx-lake') || el.id || 'reactive';
return n;
}
const ch = [];
for (const c of el.childNodes) { const s = snap(c); if (s) ch.push(s); }
if (ch.length) n.ch = ch;
return n;
}
const root = document.querySelector('#main-panel') || document.querySelector('#sx-root');
return root ? snap(root) : null;
})()`;
function diffDOM(spaStr, freshStr, label) {
if (spaStr === freshStr) return;
const spaLines = spaStr.split('\n');
const freshLines = freshStr.split('\n');
const diffs = [];
for (let i = 0; i < Math.max(spaLines.length, freshLines.length); i++) {
if (spaLines[i] !== freshLines[i]) {
diffs.push(` Line ${i+1}:`);
diffs.push(` SPA: ${(spaLines[i]||'(missing)').trim()}`);
diffs.push(` Fresh: ${(freshLines[i]||'(missing)').trim()}`);
if (diffs.length > 15) { diffs.push(' ...'); break; }
}
}
expect(spaStr === freshStr, `${label}\n${diffs.join('\n')}`).toBe(true);
}
const navRoutes = [
{ from: '(geography)', click: 'a[sx-get*="(geography.(reactive))"]:not([href*="runtime"])', url: /reactive/ },
{ from: '', click: 'a[sx-get*="(language)"]', url: /language/ },
{ from: '', click: 'a[sx-get*="(geography)"]', url: /geography/ },
];
for (const { from, click, url } of navRoutes) {
test(`SPA nav DOM matches fresh: ${from || '/'}${click.match(/\(([^)]+)\)/)?.[0] || '?'}`, async ({ page }) => {
await loadPage(page, from);
await page.click(click);
await expect(page).toHaveURL(url, { timeout: 5000 });
await page.waitForTimeout(1000);
const spaDOM = await page.evaluate(snapshotJS);
// Fresh load same URL
await page.goto(page.url(), { waitUntil: 'domcontentloaded' });
await waitForSxReady(page);
const freshDOM = await page.evaluate(snapshotJS);
diffDOM(
JSON.stringify(spaDOM, null, 2),
JSON.stringify(freshDOM, null, 2),
`SPA from ${from || '/'} to ${page.url()}`
);
});
}
});

View File

@@ -1,8 +1,11 @@
// @ts-check
const { test, expect } = require('playwright/test');
const { BASE_URL, waitForSxReady } = require('./helpers');
const { BASE_URL, waitForSxReady, trackErrors } = require('./helpers');
test.describe('Reactive Island Navigation', () => {
let t;
test.beforeEach(({ page }) => { t = trackErrors(page); });
test.afterEach(() => { expect(t.errors()).toEqual([]); });
test('counter island works on direct load', async ({ page }) => {
await page.goto(BASE_URL + '/sx/(geography.(reactive.(examples.counter)))', { waitUntil: 'domcontentloaded' });

View File

@@ -1,7 +1,8 @@
const { test, expect } = require('playwright/test');
const { loadPage } = require('./helpers');
const { loadPage, trackErrors } = require('./helpers');
test('home page stepper: no raw SX component calls visible', async ({ page }) => {
const t = trackErrors(page);
await loadPage(page, '');
const stepper = page.locator('[data-sx-island="home/stepper"]');
@@ -25,4 +26,6 @@ test('home page stepper: no raw SX component calls visible', async ({ page }) =>
await page.waitForTimeout(300);
const textAfter = await stepper.textContent();
expect(textAfter).not.toBe(textBefore);
expect(t.errors()).toEqual([]);
});