Fix aser server-affinity expansion: keyword values, OOB wrapper, page helpers

Three bugs in aser-expand-component (adapter-sx.sx):
- Keyword values were eval'd (eval-expr can't handle <>, HTML tags);
  now asered, matching the aser's rendering capabilities
- Missing default nil binding for unset &key params (caused
  "Undefined symbol" errors for optional params like header-rows)
- aserCall string-quoted keyword values that were already serialized
  SX — now inlines values starting with "(" directly

Server-affinity annotations for layout/nav shells:
- ~shared:layout/app-body, ~shared:layout/oob-sx — page structure
- ~layouts/nav-sibling-row, ~layouts/nav-children — server-side data
- ~layouts/doc already had :affinity :server
- ~cssx/flush marked :affinity :client (browser-only state)

Navigation fix: restore oob_page_sx wrapper for HTMX responses
so #main-panel section exists for sx-select/sx-swap targeting.

OCaml bridge: lazy page helper injection into kernel via IO proxy
(define name (fn (...) (helper "name" ...))) — enables aser_slot
to evaluate highlight/component-source etc. via coroutine bridge.

Playwright tests: added pageerror listener to test_no_console_errors,
new test_navigate_from_home_to_geography for HTMX nav regression.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 12:06:24 +00:00
parent 171c18d3be
commit 109ca7c70b
10 changed files with 201 additions and 31 deletions

View File

@@ -14,7 +14,7 @@
// =========================================================================
var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } });
var SX_VERSION = "2026-03-18T20:14:01Z";
var SX_VERSION = "2026-03-19T11:12:01Z";
function isNil(x) { return x === NIL || x === null || x === undefined; }
function isSxTruthy(x) { return x !== false && !isNil(x); }
@@ -1460,6 +1460,9 @@ PRIMITIVES["cek-step"] = cekStep;
var name = symbolName(expr);
return (function() {
var val = (isSxTruthy(envHas(env, name)) ? envGet(env, name) : (isSxTruthy(isPrimitive(name)) ? getPrimitive(name) : (isSxTruthy((name == "true")) ? true : (isSxTruthy((name == "false")) ? false : (isSxTruthy((name == "nil")) ? NIL : error((String("Undefined symbol: ") + String(name))))))));
if (isSxTruthy((isSxTruthy(isNil(val)) && startsWith(name, "~")))) {
debugLog("Component not found:", name);
}
return makeCekValue(val, env, kont);
})();
})(); if (_m == "keyword") return makeCekValue(keywordName(expr), env, kont); if (_m == "dict") return (function() {
@@ -2673,14 +2676,15 @@ PRIMITIVES["aser"] = aser;
var args = rest(expr);
return (isSxTruthy(!isSxTruthy((typeOf(head) == "symbol"))) ? map(function(x) { return aser(x, env); }, expr) : (function() {
var name = symbolName(head);
return (isSxTruthy((name == "<>")) ? aserFragment(args, env) : (isSxTruthy(startsWith(name, "~")) ? (function() {
return (isSxTruthy((name == "<>")) ? aserFragment(args, env) : (isSxTruthy((name == "raw!")) ? aserCall("raw!", args, env) : (isSxTruthy(startsWith(name, "~")) ? (function() {
var comp = (isSxTruthy(envHas(env, name)) ? envGet(env, name) : NIL);
return (isSxTruthy((isSxTruthy(comp) && isSxTruthy(isComponent(comp)) && (componentAffinity(comp) == "server"))) ? aserExpandComponent(comp, args, env) : aserCall(name, args, env));
var expandAll = (isSxTruthy(envHas(env, "expand-components?")) ? expandComponents_p() : false);
return (isSxTruthy((isSxTruthy(comp) && isMacro(comp))) ? aser(expandMacro(comp, args, env), env) : (isSxTruthy((isSxTruthy(comp) && isSxTruthy(isComponent(comp)) && isSxTruthy(sxOr(expandAll, (componentAffinity(comp) == "server"))) && !isSxTruthy((componentAffinity(comp) == "client")))) ? aserExpandComponent(comp, args, env) : aserCall(name, args, env)));
})() : (isSxTruthy((name == "lake")) ? aserCall(name, args, env) : (isSxTruthy((name == "marsh")) ? aserCall(name, args, env) : (isSxTruthy(contains(HTML_TAGS, name)) ? aserCall(name, args, env) : (isSxTruthy(sxOr(isSpecialForm(name), isHoForm(name))) ? aserSpecial(name, expr, env) : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? aser(expandMacro(envGet(env, name), args, env), env) : (function() {
var f = trampoline(evalExpr(head, env));
var evaledArgs = map(function(a) { return trampoline(evalExpr(a, env)); }, args);
return (isSxTruthy((isSxTruthy(isCallable(f)) && isSxTruthy(!isSxTruthy(isLambda(f))) && isSxTruthy(!isSxTruthy(isComponent(f))) && !isSxTruthy(isIsland(f)))) ? apply(f, evaledArgs) : (isSxTruthy(isLambda(f)) ? trampoline(callLambda(f, evaledArgs, env)) : (isSxTruthy(isComponent(f)) ? aserCall((String("~") + String(componentName(f))), args, env) : (isSxTruthy(isIsland(f)) ? aserCall((String("~") + String(componentName(f))), args, env) : error((String("Not callable: ") + String(inspect(f))))))));
})())))))));
})()))))))));
})());
})(); };
PRIMITIVES["aser-list"] = aserList;
@@ -2707,7 +2711,7 @@ PRIMITIVES["aser-fragment"] = aserFragment;
var val = aser(nth(args, (i + 1)), env);
if (isSxTruthy(!isSxTruthy(isNil(val)))) {
attrParts.push((String(":") + String(keywordName(arg))));
attrParts.push(serialize(val));
(isSxTruthy((isSxTruthy((typeOf(val) == "string")) && isSxTruthy((stringLength(val) > 0)) && startsWith(val, "("))) ? append_b(attrParts, val) : append_b(attrParts, serialize(val)));
}
skip = true;
return (i = (i + 1));
@@ -2738,7 +2742,8 @@ PRIMITIVES["aser-call"] = aserCall;
var i = 0;
var skip = false;
var children = [];
{ var _c = args; for (var _i = 0; _i < _c.length; _i++) { var arg = _c[_i]; (isSxTruthy(skip) ? ((skip = false), (i = (i + 1))) : (isSxTruthy((isSxTruthy((typeOf(arg) == "keyword")) && ((i + 1) < len(args)))) ? (envBind(local, keywordName(arg), trampoline(evalExpr(nth(args, (i + 1)), env))), (skip = true), (i = (i + 1))) : (append_b(children, arg), (i = (i + 1))))); } }
{ var _c = params; for (var _i = 0; _i < _c.length; _i++) { var p = _c[_i]; envBind(local, p, NIL); } }
{ var _c = args; for (var _i = 0; _i < _c.length; _i++) { var arg = _c[_i]; (isSxTruthy(skip) ? ((skip = false), (i = (i + 1))) : (isSxTruthy((isSxTruthy((typeOf(arg) == "keyword")) && ((i + 1) < len(args)))) ? (envBind(local, keywordName(arg), aser(nth(args, (i + 1)), env)), (skip = true), (i = (i + 1))) : (append_b(children, arg), (i = (i + 1))))); } }
if (isSxTruthy(componentHasChildren(comp))) {
(function() {
var aseredChildren = map(function(c) { return aser(c, env); }, children);

View File

@@ -364,10 +364,6 @@ async def _render_to_sx_with_env(__name: str, extra_env: dict, **kwargs: Any) ->
"""
from .jinja_bridge import get_component_env, _get_request_context
import os
if os.environ.get("SX_USE_REF") == "1":
from .ref.async_eval_ref import async_eval_slot_to_sx
else:
from .async_eval import async_eval_slot_to_sx
from .types import Symbol, Keyword, NIL as _NIL
# Build AST with extra_env entries as keyword args so _aser_component
@@ -381,6 +377,19 @@ async def _render_to_sx_with_env(__name: str, extra_env: dict, **kwargs: Any) ->
ast.append(Keyword(k.replace("_", "-")))
ast.append(v if v is not None else _NIL)
if os.environ.get("SX_USE_OCAML") == "1":
from .ocaml_bridge import get_bridge
from .parser import serialize
bridge = await get_bridge()
sx_text = serialize(ast)
ocaml_ctx = {"_helper_service": _get_request_context().get("_helper_service", "")} if isinstance(_get_request_context(), dict) else {}
return SxExpr(await bridge.aser_slot(sx_text, ctx=ocaml_ctx))
if os.environ.get("SX_USE_REF") == "1":
from .ref.async_eval_ref import async_eval_slot_to_sx
else:
from .async_eval import async_eval_slot_to_sx
env = dict(get_component_env())
env.update(extra_env)
ctx = _get_request_context()
@@ -399,12 +408,21 @@ async def _render_to_sx(__name: str, **kwargs: Any) -> str:
"""
from .jinja_bridge import get_component_env, _get_request_context
import os
ast = _build_component_ast(__name, **kwargs)
if os.environ.get("SX_USE_OCAML") == "1":
from .ocaml_bridge import get_bridge
from .parser import serialize
bridge = await get_bridge()
sx_text = serialize(ast)
return SxExpr(await bridge.aser(sx_text))
if os.environ.get("SX_USE_REF") == "1":
from .ref.async_eval_ref import async_eval_to_sx
else:
from .async_eval import async_eval_to_sx
ast = _build_component_ast(__name, **kwargs)
env = dict(get_component_env())
ctx = _get_request_context()
return SxExpr(await async_eval_to_sx(ast, env, ctx))
@@ -420,6 +438,10 @@ async def render_to_html(__name: str, **kwargs: Any) -> str:
Same as render_to_sx() but produces HTML output instead of SX wire
format. Used by route renders that need HTML (full pages, fragments).
Note: does NOT use OCaml bridge — the shell render is a pure HTML
template with no IO, so the Python renderer handles it reliably.
The OCaml path is used for _render_to_sx and _eval_slot (IO-heavy).
"""
from .jinja_bridge import get_component_env, _get_request_context
import os

View File

@@ -43,6 +43,7 @@ class OcamlBridge:
self._lock = asyncio.Lock()
self._started = False
self._components_loaded = False
self._helpers_injected = False
async def start(self) -> None:
"""Launch the OCaml subprocess and wait for (ready)."""
@@ -141,6 +142,56 @@ class OcamlBridge:
self._send(f'(aser "{_escape(source)}")')
return await self._read_until_ok(ctx)
async def aser_slot(self, source: str, ctx: dict[str, Any] | None = None) -> str:
"""Like aser() but expands ALL components server-side.
Equivalent to Python's async_eval_slot_to_sx — used for layout
slots where component bodies need server-side IO evaluation.
"""
await self._ensure_components()
await self._ensure_helpers()
async with self._lock:
self._send(f'(aser-slot "{_escape(source)}")')
return await self._read_until_ok(ctx)
async def _ensure_helpers(self) -> None:
"""Lazily inject page helpers into the kernel as IO proxies."""
if self._helpers_injected:
return
self._helpers_injected = True
try:
from .pages import get_page_helpers
helpers = get_page_helpers("sx")
if not helpers:
self._helpers_injected = False # retry later
return
async with self._lock:
for name, fn in helpers.items():
if callable(fn) and not name.startswith("~"):
# Determine arity from Python function signature
import inspect
try:
sig = inspect.signature(fn)
nargs = sum(1 for p in sig.parameters.values()
if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD))
except (ValueError, TypeError):
nargs = 2
nargs = max(nargs, 1) # at least 1 arg
param_names = " ".join(chr(97 + i) for i in range(nargs))
arg_list = " ".join(chr(97 + i) for i in range(nargs))
sx_def = f'(define {name} (fn ({param_names}) (helper "{name}" {arg_list})))'
try:
self._send(f'(load-source "{_escape(sx_def)}")')
await self._read_until_ok(ctx=None)
except OcamlBridgeError:
pass
_logger.info("Injected %d page helpers into OCaml kernel",
sum(1 for n, f in helpers.items()
if callable(f) and not n.startswith("~")))
except Exception as e:
_logger.warning("Helper injection failed: %s", e)
self._helpers_injected = False
async def _ensure_components(self) -> None:
"""Load all .sx source files into the kernel on first use.
@@ -214,6 +265,27 @@ class OcamlBridge:
_logger.error("Failed to load .sx files into OCaml kernel: %s", e)
self._components_loaded = False # retry next time
async def inject_page_helpers(self, helpers: dict) -> None:
"""Register page helpers as IO-routing definitions in the kernel.
Each helper becomes a function that yields (io-request "helper" name ...),
routing the call back to Python via the coroutine bridge.
"""
await self._ensure_components()
async with self._lock:
count = 0
for name, fn in helpers.items():
if callable(fn) and not name.startswith("~"):
sx_def = f'(define {name} (fn (&rest args) (apply helper (concat (list "{name}") args))))'
try:
self._send(f'(load-source "{_escape(sx_def)}")')
await self._read_until_ok(ctx=None)
count += 1
except OcamlBridgeError:
pass # non-fatal
if count:
_logger.info("Injected %d page helpers into OCaml kernel", count)
async def reset(self) -> None:
"""Reset the kernel environment to pristine state."""
async with self._lock:

View File

@@ -493,7 +493,7 @@
;; (~cssx/flush)
;; =========================================================================
(defcomp ~cssx/flush ()
(defcomp ~cssx/flush () :affinity :client
(let ((rules (collected "cssx")))
(clear-collected! "cssx")
(when (not (empty? rules))

View File

@@ -1,4 +1,4 @@
(defcomp ~shared:layout/app-body (&key header-rows filter aside menu content)
(defcomp ~shared:layout/app-body (&key header-rows filter aside menu content) :affinity :server
(div :class "max-w-screen-2xl mx-auto py-1 px-1"
(when header-rows
(div :class "w-full"
@@ -24,7 +24,7 @@
(when content content)
(div :class "pb-8")))))))
(defcomp ~shared:layout/oob-sx (&key oobs filter aside menu content)
(defcomp ~shared:layout/oob-sx (&key oobs filter aside menu content) :affinity :server
(<>
(when oobs oobs)
(div :id "filter" :sx-swap-oob "outerHTML"