From 8a08de26cd2ae461de2b91fbfb0aca12c3339be1 Mon Sep 17 00:00:00 2001 From: giles Date: Tue, 24 Mar 2026 10:01:41 +0000 Subject: [PATCH] Web extension module for def-forms + modifier-key clicks + CSSX SSR fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move defhandler/defquery/defaction/defpage/defrelation from hardcoded evaluator dispatch to web/web-forms.sx extension module, registered via register-special-form!. Adapters updated to use definition-form? and dynamically extended form-name lists. Fix modifier-key clicks (ctrl-click → new tab) in three click handlers: bindBoostLink, bindClientRouteClick, and orchestration.sx bind-event. Add event-modifier-key? primitive (eventModifierKey_p for transpiler). Fix CSSX SSR: ~cssx/flush no longer drains the collected bucket on the server, so the shell template correctly emits CSSX rules in . Add missing server-side DOM stubs (create-text-node, dom-append, etc.) and SSR passthrough for portal/error-boundary/promise-delayed. Passive event listeners for touch/wheel/scroll to fix touchpad scrolling. 97/97 Playwright demo tests + 4/4 isomorphic SSR tests pass. Co-Authored-By: Claude Opus 4.6 (1M context) --- hosts/javascript/platform.py | 12 +- hosts/ocaml/bin/sx_server.ml | 32 ++-- shared/static/scripts/sx-browser.js | 28 ++-- shared/sx/ocaml_bridge.py | 5 +- shared/sx/templates/cssx.sx | 23 ++- spec/render.sx | 2 +- web/adapter-async.sx | 8 +- web/adapter-dom.sx | 2 +- web/adapter-html.sx | 7 +- web/orchestration.sx | 7 +- web/web-forms.sx | 240 ++++++++++++++++++++++++++++ 11 files changed, 318 insertions(+), 48 deletions(-) create mode 100644 web/web-forms.sx diff --git a/hosts/javascript/platform.py b/hosts/javascript/platform.py index d35467c..92fb08d 100644 --- a/hosts/javascript/platform.py +++ b/hosts/javascript/platform.py @@ -2047,8 +2047,10 @@ PLATFORM_DOM_JS = """ : function(e) { try { cekCall(handler, [e]); } catch(err) { console.error("[sx-ref] domListen handler error:", name, err); } finally { runPostRenderHooks(); } }) : handler; if (name === "click") logInfo("domListen: click on <" + (el.tagName||"?").toLowerCase() + "> text=" + (el.textContent||"").substring(0,20) + " isLambda=" + isLambda(handler)); - el.addEventListener(name, wrapped); - return function() { el.removeEventListener(name, wrapped); }; + var passiveEvents = { touchstart: 1, touchmove: 1, wheel: 1, scroll: 1 }; + var opts = passiveEvents[name] ? { passive: true } : undefined; + el.addEventListener(name, wrapped, opts); + return function() { el.removeEventListener(name, wrapped, opts); }; } function eventDetail(e) { @@ -2670,6 +2672,7 @@ PLATFORM_ORCHESTRATION_JS = """ function preventDefault_(e) { if (e && e.preventDefault) e.preventDefault(); } function stopPropagation_(e) { if (e && e.stopPropagation) e.stopPropagation(); } + function eventModifierKey_p(e) { return !!(e && (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey)); } function domFocus(el) { if (el && el.focus) el.focus(); } function tryCatch(tryFn, catchFn) { var t = _wrapSxFn(tryFn); @@ -2773,6 +2776,7 @@ PLATFORM_ORCHESTRATION_JS = """ function bindBoostLink(el, _href) { el.addEventListener("click", function(e) { + if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return; e.preventDefault(); // Re-read href from element at click time (not closed-over value) var liveHref = el.getAttribute("href") || _href; @@ -2804,6 +2808,7 @@ PLATFORM_ORCHESTRATION_JS = """ function bindClientRouteClick(link, _href, fallbackFn) { link.addEventListener("click", function(e) { + if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return; e.preventDefault(); // Re-read href from element at click time (not closed-over value) var liveHref = link.getAttribute("href") || _href; @@ -2954,7 +2959,7 @@ PLATFORM_ORCHESTRATION_JS = """ } else { fn(); } - }); + }, { passive: true }); }); } @@ -3283,6 +3288,7 @@ def fixups_js(has_html, has_sx, has_dom, has_signals=False, has_deps=False, has_ if (typeof domClosest === "function") PRIMITIVES["dom-closest"] = domClosest; if (typeof domMatches === "function") PRIMITIVES["dom-matches?"] = domMatches; if (typeof preventDefault_ === "function") PRIMITIVES["prevent-default"] = preventDefault_; + if (typeof eventModifierKey_p === "function") PRIMITIVES["event-modifier-key?"] = eventModifierKey_p; if (typeof elementValue === "function") PRIMITIVES["element-value"] = elementValue; if (typeof domOuterHtml === "function") PRIMITIVES["dom-outer-html"] = domOuterHtml; if (typeof domInnerHtml === "function") PRIMITIVES["dom-inner-html"] = domInnerHtml; diff --git a/hosts/ocaml/bin/sx_server.ml b/hosts/ocaml/bin/sx_server.ml index be38e02..6690edc 100644 --- a/hosts/ocaml/bin/sx_server.ml +++ b/hosts/ocaml/bin/sx_server.ml @@ -125,11 +125,9 @@ let io_batch_mode = ref false let io_queue : (int * string * value list) list ref = ref [] let io_counter = ref 0 -(** Module-level scope stacks — shared between make_server_env (aser - scope-push!/pop!) and step-sf-context (via get-primitive "scope-peek"). *) -(** Request cookies — set by Python bridge before each page render. - get-cookie reads from here on the server; set-cookie is a no-op - (server can't set response cookies from SX — that's the framework's job). *) +(* Request cookies — set by Python bridge before each page render. + get-cookie reads from here on the server; set-cookie is a no-op + (server can't set response cookies from SX — that's the framework's job). *) let _request_cookies : (string, string) Hashtbl.t = Hashtbl.create 8 let () = Sx_primitives.register "get-cookie" (fun args -> @@ -144,6 +142,8 @@ let () = Sx_primitives.register "set-cookie" (fun _args -> (* No-op on server — cookies are set via HTTP response headers *) Nil) +(* Module-level scope stacks — shared between make_server_env (aser + scope-push!/pop!) and step-sf-context (via get-primitive "scope-peek"). *) let _scope_stacks : (string, value list) Hashtbl.t = Hashtbl.create 8 let () = Sx_primitives.register "scope-push!" (fun args -> @@ -496,6 +496,10 @@ let make_server_env () = bind "dom-text-content" (fun _args -> String ""); bind "dom-set-text-content" (fun _args -> Nil); bind "dom-body" (fun _args -> Nil); + bind "dom-create-element" (fun _args -> Nil); + bind "dom-append" (fun _args -> Nil); + bind "create-text-node" (fun _args -> Nil); + bind "render-to-dom" (fun _args -> Nil); (* Raw HTML — platform primitives for adapter-html.sx *) bind "make-raw-html" (fun args -> @@ -617,6 +621,16 @@ let make_server_env () = Sx_ref.eval_expr m.m_body (Env body_env) | _ -> raise (Eval_error "expand-macro: expected (macro args env)")); + (* Expose register-special-form! and *custom-special-forms* to SX code + (used by web-forms.sx and adapter form-classification functions) *) + bind "register-special-form!" (fun args -> + match args with + | [String name; handler] -> + ignore (Sx_ref.register_special_form (String name) handler); + Nil + | _ -> raise (Eval_error "register-special-form!: expected (name handler)")); + ignore (env_bind env "*custom-special-forms*" Sx_ref.custom_special_forms); + (* Register <> as a special form — evaluates all children, returns list *) ignore (Sx_ref.register_special_form (String "<>") (NativeFn ("<>", fun args -> List (List.map (fun a -> Sx_ref.eval_expr a (Env env)) args)))); @@ -725,10 +739,8 @@ let make_server_env () = bind "set-response-status" (fun args -> io_request "set-response-status" args); bind "set-response-header" (fun args -> io_request "set-response-header" args); - (* Application constructs — no-ops in the kernel, but needed so - handler/page files load successfully (their define forms get evaluated) *) - ignore (Sx_ref.register_special_form (String "defhandler") (NativeFn ("defhandler", fun _args -> Nil))); - ignore (Sx_ref.register_special_form (String "defpage") (NativeFn ("defpage", fun _args -> Nil))); + (* defhandler/defpage/defquery/defaction/defrelation are registered by + web-forms.sx via register-special-form!, no longer hardcoded here. *) bind "cond-scheme?" (fun args -> match args with @@ -1504,6 +1516,7 @@ let cli_mode mode = Filename.concat base "render.sx"; Filename.concat web_base "adapter-html.sx"; Filename.concat web_base "adapter-sx.sx"; + Filename.concat web_base "web-forms.sx"; ] in (* Load spec files for all CLI modes that need rendering *) (if mode = "aser" || mode = "aser-slot" || mode = "render" then @@ -1588,6 +1601,7 @@ let test_mode () = Filename.concat web_base "signals.sx"; Filename.concat web_base "adapter-html.sx"; Filename.concat web_base "adapter-sx.sx"; + Filename.concat web_base "web-forms.sx"; ] in cli_load_files env files; (* Register JIT *) diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js index 5928da0..bd29fbf 100644 --- a/shared/static/scripts/sx-browser.js +++ b/shared/static/scripts/sx-browser.js @@ -14,7 +14,7 @@ // ========================================================================= var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } }); - var SX_VERSION = "2026-03-24T04:23:51Z"; + var SX_VERSION = "2026-03-24T09:53:22Z"; function isNil(x) { return x === NIL || x === null || x === undefined; } function isSxTruthy(x) { return x !== false && !isNil(x); } @@ -2237,7 +2237,7 @@ PRIMITIVES["VOID_ELEMENTS"] = VOID_ELEMENTS; PRIMITIVES["BOOLEAN_ATTRS"] = BOOLEAN_ATTRS; // definition-form? - var isDefinitionForm = function(name) { return sxOr((name == "define"), (name == "defcomp"), (name == "defisland"), (name == "defmacro"), (name == "defstyle"), (name == "defhandler"), (name == "deftype"), (name == "defeffect")); }; + var isDefinitionForm = function(name) { return sxOr((name == "define"), (name == "defcomp"), (name == "defisland"), (name == "defmacro"), (name == "defstyle"), (name == "deftype"), (name == "defeffect")); }; PRIMITIVES["definition-form?"] = isDefinitionForm; // parse-element-args @@ -2520,7 +2520,7 @@ PRIMITIVES["render-to-html"] = renderToHtml; PRIMITIVES["render-value-to-html"] = renderValueToHtml; // RENDER_HTML_FORMS - var RENDER_HTML_FORMS = ["if", "when", "cond", "case", "let", "let*", "letrec", "begin", "do", "define", "defcomp", "defisland", "defmacro", "defstyle", "defhandler", "deftype", "defeffect", "map", "map-indexed", "filter", "for-each", "scope", "provide"]; + var RENDER_HTML_FORMS = ["if", "when", "cond", "case", "let", "let*", "letrec", "begin", "do", "define", "defcomp", "defisland", "defmacro", "defstyle", "deftype", "defeffect", "map", "map-indexed", "filter", "for-each", "scope", "provide"]; PRIMITIVES["RENDER_HTML_FORMS"] = RENDER_HTML_FORMS; // render-html-form? @@ -2533,10 +2533,10 @@ PRIMITIVES["render-html-form?"] = isRenderHtmlForm; return (isSxTruthy(!isSxTruthy((typeOf(head) == "symbol"))) ? join("", map(function(x) { return renderValueToHtml(x, env); }, expr)) : (function() { var name = symbolName(head); var args = rest(expr); - return (isSxTruthy((name == "<>")) ? join("", map(function(x) { return renderToHtml(x, env); }, args)) : (isSxTruthy((name == "raw!")) ? join("", map(function(x) { return (String(trampoline(evalExpr(x, env)))); }, args)) : (isSxTruthy((name == "lake")) ? renderHtmlLake(args, env) : (isSxTruthy((name == "marsh")) ? renderHtmlMarsh(args, env) : (isSxTruthy(contains(HTML_TAGS, name)) ? renderHtmlElement(name, args, env) : (isSxTruthy((isSxTruthy(startsWith(name, "~")) && isSxTruthy(envHas(env, name)) && isIsland(envGet(env, name)))) ? renderHtmlIsland(envGet(env, name), args, env) : (isSxTruthy(startsWith(name, "~")) ? (function() { + return (isSxTruthy((name == "<>")) ? join("", map(function(x) { return renderToHtml(x, env); }, args)) : (isSxTruthy((name == "raw!")) ? join("", map(function(x) { return (String(trampoline(evalExpr(x, env)))); }, args)) : (isSxTruthy((name == "lake")) ? renderHtmlLake(args, env) : (isSxTruthy((name == "marsh")) ? renderHtmlMarsh(args, env) : (isSxTruthy(sxOr((name == "portal"), (name == "error-boundary"), (name == "promise-delayed"))) ? join("", map(function(x) { return renderToHtml(x, env); }, args)) : (isSxTruthy(contains(HTML_TAGS, name)) ? renderHtmlElement(name, args, env) : (isSxTruthy((isSxTruthy(startsWith(name, "~")) && isSxTruthy(envHas(env, name)) && isIsland(envGet(env, name)))) ? renderHtmlIsland(envGet(env, name), args, env) : (isSxTruthy(startsWith(name, "~")) ? (function() { var val = envGet(env, name); return (isSxTruthy(isComponent(val)) ? renderHtmlComponent(val, args, env) : (isSxTruthy(isMacro(val)) ? renderToHtml(expandMacro(val, args, env), env) : error((String("Unknown component: ") + String(name))))); -})() : (isSxTruthy(isRenderHtmlForm(name)) ? dispatchHtmlForm(name, expr, env) : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? renderToHtml(expandMacro(envGet(env, name), args, env), env) : renderValueToHtml(trampoline(evalExpr(expr, env)), env)))))))))); +})() : (isSxTruthy(isRenderHtmlForm(name)) ? dispatchHtmlForm(name, expr, env) : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? renderToHtml(expandMacro(envGet(env, name), args, env), env) : renderValueToHtml(trampoline(evalExpr(expr, env)), env))))))))))); })()); })()); }; PRIMITIVES["render-list-to-html"] = renderListToHtml; @@ -3110,7 +3110,7 @@ PRIMITIVES["render-dom-raw"] = renderDomRaw; PRIMITIVES["render-dom-unknown-component"] = renderDomUnknownComponent; // RENDER_DOM_FORMS - var RENDER_DOM_FORMS = ["if", "when", "cond", "case", "let", "let*", "begin", "do", "define", "defcomp", "defisland", "defmacro", "defstyle", "defhandler", "map", "map-indexed", "filter", "for-each", "portal", "error-boundary", "scope", "provide"]; + var RENDER_DOM_FORMS = ["if", "when", "cond", "case", "let", "let*", "begin", "do", "define", "defcomp", "defisland", "defmacro", "defstyle", "map", "map-indexed", "filter", "for-each", "portal", "error-boundary", "scope", "provide"]; PRIMITIVES["RENDER_DOM_FORMS"] = RENDER_DOM_FORMS; // render-dom-form? @@ -4181,7 +4181,7 @@ PRIMITIVES["bind-triggers"] = bindTriggers; return (isSxTruthy((val == lastVal)) ? (shouldFire = false) : (lastVal = val)); })(); } - return (isSxTruthy(shouldFire) ? ((isSxTruthy(sxOr((eventName == "submit"), (isSxTruthy((eventName == "click")) && domHasAttr(el, "href")))) ? preventDefault_(e) : NIL), (function() { + return (isSxTruthy(shouldFire) ? ((isSxTruthy((isSxTruthy((eventName == "click")) && eventModifierKey_p(e))) ? (shouldFire = false) : NIL), (isSxTruthy(shouldFire) ? ((isSxTruthy(sxOr((eventName == "submit"), (isSxTruthy((eventName == "click")) && domHasAttr(el, "href")))) ? preventDefault_(e) : NIL), (function() { var liveInfo = sxOr(getVerbInfo(el), verbInfo); var isGetLink = (isSxTruthy((eventName == "click")) && isSxTruthy((get(liveInfo, "method") == "GET")) && isSxTruthy(domHasAttr(el, "href")) && !isSxTruthy(get(mods, "delay"))); var clientRouted = false; @@ -4189,7 +4189,7 @@ PRIMITIVES["bind-triggers"] = bindTriggers; clientRouted = tryClientRoute(urlPathname(get(liveInfo, "url")), domGetAttr(el, "sx-target")); } return (isSxTruthy(clientRouted) ? (browserPushState(get(liveInfo, "url")), browserScrollTo(0, 0)) : ((isSxTruthy(isGetLink) ? logInfo((String("sx:route server fetch ") + String(get(liveInfo, "url")))) : NIL), (isSxTruthy(get(mods, "delay")) ? (clearTimeout_(timer), (timer = setTimeout_(function() { return executeRequest(el, NIL, NIL); }, get(mods, "delay")))) : executeRequest(el, NIL, NIL)))); -})()) : NIL); +})()) : NIL)) : NIL); })(); }, (isSxTruthy(get(mods, "once")) ? {["once"]: true} : NIL)) : NIL); })(); }; PRIMITIVES["bind-event"] = bindEvent; @@ -5880,6 +5880,7 @@ PRIMITIVES["resource"] = resource; if (typeof domClosest === "function") PRIMITIVES["dom-closest"] = domClosest; if (typeof domMatches === "function") PRIMITIVES["dom-matches?"] = domMatches; if (typeof preventDefault_ === "function") PRIMITIVES["prevent-default"] = preventDefault_; + if (typeof eventModifierKey_p === "function") PRIMITIVES["event-modifier-key?"] = eventModifierKey_p; if (typeof elementValue === "function") PRIMITIVES["element-value"] = elementValue; if (typeof domOuterHtml === "function") PRIMITIVES["dom-outer-html"] = domOuterHtml; if (typeof domInnerHtml === "function") PRIMITIVES["dom-inner-html"] = domInnerHtml; @@ -6183,8 +6184,10 @@ PRIMITIVES["resource"] = resource; : function(e) { try { cekCall(handler, [e]); } catch(err) { console.error("[sx-ref] domListen handler error:", name, err); } finally { runPostRenderHooks(); } }) : handler; if (name === "click") logInfo("domListen: click on <" + (el.tagName||"?").toLowerCase() + "> text=" + (el.textContent||"").substring(0,20) + " isLambda=" + isLambda(handler)); - el.addEventListener(name, wrapped); - return function() { el.removeEventListener(name, wrapped); }; + var passiveEvents = { touchstart: 1, touchmove: 1, wheel: 1, scroll: 1 }; + var opts = passiveEvents[name] ? { passive: true } : undefined; + el.addEventListener(name, wrapped, opts); + return function() { el.removeEventListener(name, wrapped, opts); }; } function eventDetail(e) { @@ -6802,6 +6805,7 @@ PRIMITIVES["resource"] = resource; function preventDefault_(e) { if (e && e.preventDefault) e.preventDefault(); } function stopPropagation_(e) { if (e && e.stopPropagation) e.stopPropagation(); } + function eventModifierKey_p(e) { return !!(e && (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey)); } function domFocus(el) { if (el && el.focus) el.focus(); } function tryCatch(tryFn, catchFn) { var t = _wrapSxFn(tryFn); @@ -6905,6 +6909,7 @@ PRIMITIVES["resource"] = resource; function bindBoostLink(el, _href) { el.addEventListener("click", function(e) { + if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return; e.preventDefault(); // Re-read href from element at click time (not closed-over value) var liveHref = el.getAttribute("href") || _href; @@ -6936,6 +6941,7 @@ PRIMITIVES["resource"] = resource; function bindClientRouteClick(link, _href, fallbackFn) { link.addEventListener("click", function(e) { + if (e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) return; e.preventDefault(); // Re-read href from element at click time (not closed-over value) var liveHref = link.getAttribute("href") || _href; @@ -7086,7 +7092,7 @@ PRIMITIVES["resource"] = resource; } else { fn(); } - }); + }, { passive: true }); }); } diff --git a/shared/sx/ocaml_bridge.py b/shared/sx/ocaml_bridge.py index 85b56cf..fc3b747 100644 --- a/shared/sx/ocaml_bridge.py +++ b/shared/sx/ocaml_bridge.py @@ -420,10 +420,11 @@ class OcamlBridge: # All directories loaded into the Python env all_dirs = list(set(_watched_dirs) | _dirs_from_cache) - # Isomorphic libraries: signals, rendering, freeze scopes + # Isomorphic libraries: signals, rendering, freeze scopes, web forms web_dir = os.path.join(os.path.dirname(__file__), "../../web") if os.path.isdir(web_dir): - for web_file in ["signals.sx", "adapter-html.sx", "adapter-sx.sx"]: + for web_file in ["signals.sx", "adapter-html.sx", "adapter-sx.sx", + "web-forms.sx"]: path = os.path.normpath(os.path.join(web_dir, web_file)) if os.path.isfile(path): all_files.append(path) diff --git a/shared/sx/templates/cssx.sx b/shared/sx/templates/cssx.sx index 3200b2b..6dc8df5 100644 --- a/shared/sx/templates/cssx.sx +++ b/shared/sx/templates/cssx.sx @@ -494,16 +494,13 @@ ;; ========================================================================= (defcomp ~cssx/flush () :affinity :client - (let ((rules (collected "cssx"))) - (clear-collected! "cssx") - (when (not (empty? rules)) - ;; Append to the persistent "))))))) + (let ((rules (collected "cssx")) + (head-style (dom-query "#sx-css"))) + ;; On client: append rules to