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

@@ -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: