aser_slot migration: single-pass expansion, pipe desync fix, _render_to_sx
Three fixes completing the aser_slot migration: 1. Single-pass full-page rendering: eval_sx_url builds layout+content AST and aser_slots it in ONE call — avoids double-aser where re-parsed content hits "Undefined symbol: title/deref" errors. 2. Pipe desync fix: _inject_helpers_locked runs INSIDE the aser_slot lock acquisition (not as a separate lock). Prevents interleaved commands from other coroutines between injection and aser-slot. 3. _render_to_sx uses aser_slot (not aser): layout wrappers like oob_page_sx contain re-parsed content from earlier aser_slot calls. Regular aser fails on symbols that were bound during the earlier expansion. aser_slot handles them correctly. HTMX path: aser_slot the content, then oob_page_sx wraps it. Full page path: build (~shared:layout/app-body :content wrapped_ast), aser_slot in one pass, pass directly to sx_page. New Playwright tests: test_navigate_geography_to_reactive, test_direct_load_reactive_page. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,7 +14,7 @@
|
||||
// =========================================================================
|
||||
|
||||
var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } });
|
||||
var SX_VERSION = "2026-03-19T12:40:33Z";
|
||||
var SX_VERSION = "2026-03-19T14:05:23Z";
|
||||
|
||||
function isNil(x) { return x === NIL || x === null || x === undefined; }
|
||||
function isSxTruthy(x) { return x !== false && !isNil(x); }
|
||||
|
||||
@@ -416,7 +416,10 @@ async def _render_to_sx(__name: str, **kwargs: Any) -> str:
|
||||
from .parser import serialize
|
||||
bridge = await get_bridge()
|
||||
sx_text = serialize(ast)
|
||||
return SxExpr(await bridge.aser(sx_text))
|
||||
# aser_slot (not aser) — layout wrappers contain re-parsed
|
||||
# content from earlier aser_slot calls. Regular aser fails on
|
||||
# symbols like `title` that were bound during the earlier expansion.
|
||||
return SxExpr(await bridge.aser_slot(sx_text))
|
||||
|
||||
if os.environ.get("SX_USE_REF") == "1":
|
||||
from .ref.async_eval_ref import async_eval_to_sx
|
||||
|
||||
@@ -149,45 +149,46 @@ class OcamlBridge:
|
||||
slots where component bodies need server-side IO evaluation.
|
||||
"""
|
||||
await self._ensure_components()
|
||||
await self._ensure_helpers()
|
||||
async with self._lock:
|
||||
# Inject helpers inside the lock to avoid pipe desync —
|
||||
# a separate lock acquisition could let another coroutine
|
||||
# interleave commands between injection and aser-slot.
|
||||
await self._inject_helpers_locked()
|
||||
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."""
|
||||
async def _inject_helpers_locked(self) -> None:
|
||||
"""Inject page helpers into the kernel. MUST be called with lock held."""
|
||||
if self._helpers_injected:
|
||||
return
|
||||
self._helpers_injected = True
|
||||
try:
|
||||
from .pages import get_page_helpers
|
||||
import inspect
|
||||
helpers = get_page_helpers("sx")
|
||||
if not helpers:
|
||||
self._helpers_injected = False # retry later
|
||||
self._helpers_injected = False
|
||||
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("~")))
|
||||
count = 0
|
||||
for name, fn in helpers.items():
|
||||
if callable(fn) and not name.startswith("~"):
|
||||
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)
|
||||
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)
|
||||
count += 1
|
||||
except OcamlBridgeError:
|
||||
pass
|
||||
_logger.info("Injected %d page helpers into OCaml kernel", count)
|
||||
except Exception as e:
|
||||
_logger.warning("Helper injection failed: %s", e)
|
||||
self._helpers_injected = False
|
||||
|
||||
@@ -90,7 +90,7 @@ async def eval_sx_url(raw_path: str) -> Any:
|
||||
from shared.sx.jinja_bridge import get_component_env, _get_request_context
|
||||
from shared.sx.pages import get_page, get_page_helpers, _eval_slot
|
||||
from shared.sx.types import Symbol, Keyword
|
||||
from shared.sx.helpers import full_page_sx, oob_page_sx, sx_response
|
||||
from shared.sx.helpers import full_page_sx, oob_page_sx, sx_response, sx_page
|
||||
from shared.sx.page import get_template_context
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
from shared.sx.ref.sx_ref import prepare_url_expr
|
||||
@@ -200,16 +200,44 @@ async def eval_sx_url(raw_path: str) -> Any:
|
||||
from shared.sx.parser import serialize
|
||||
from shared.sx.types import SxExpr
|
||||
bridge = await get_bridge()
|
||||
sx_text = serialize(wrapped_ast)
|
||||
ocaml_ctx = {"_helper_service": "sx"}
|
||||
content_sx = SxExpr(await bridge.aser(sx_text, ctx=ocaml_ctx))
|
||||
|
||||
if is_htmx_request():
|
||||
# HTMX: aser_slot the content, wrap in OOB layout
|
||||
content_sx = SxExpr(await bridge.aser_slot(
|
||||
serialize(wrapped_ast), ctx=ocaml_ctx))
|
||||
return sx_response(await oob_page_sx(content=content_sx))
|
||||
else:
|
||||
# Full page: build layout+content AST and aser_slot
|
||||
# in ONE pass — avoids double-aser that breaks when
|
||||
# re-parsed content contains islands/reactive symbols.
|
||||
full_ast = [
|
||||
Symbol("~shared:layout/app-body"),
|
||||
Keyword("content"), wrapped_ast,
|
||||
]
|
||||
full_text = serialize(full_ast)
|
||||
has_nl = chr(10) in full_text
|
||||
if has_nl:
|
||||
logger.error("NEWLINE in aser_slot input at char %d!",
|
||||
full_text.index(chr(10)))
|
||||
import time as _time
|
||||
_t0 = _time.monotonic()
|
||||
body_sx = SxExpr(await bridge.aser_slot(
|
||||
full_text, ctx=ocaml_ctx))
|
||||
_elapsed = _time.monotonic() - _t0
|
||||
logger.info("aser_slot: %.1fs, input=%d chars, output=%d chars, starts=%s",
|
||||
_elapsed, len(full_text), len(body_sx),
|
||||
str(body_sx)[:100])
|
||||
tctx = await get_template_context()
|
||||
return await make_response(
|
||||
await sx_page(tctx, body_sx), 200)
|
||||
else:
|
||||
content_sx = await _eval_slot(wrapped_ast, env, ctx)
|
||||
except Exception as e:
|
||||
logger.error("SX URL render failed for %s: %s", raw_path, e, exc_info=True)
|
||||
return None
|
||||
|
||||
# Return response — Python wraps in page shell (CSS, scripts, headers)
|
||||
# Return response (Python path)
|
||||
if is_htmx_request():
|
||||
return sx_response(await oob_page_sx(content=content_sx))
|
||||
else:
|
||||
|
||||
@@ -537,6 +537,32 @@ class TestHomePage:
|
||||
assert not fatal, f"JS errors after navigation: {fatal}"
|
||||
|
||||
|
||||
def test_navigate_geography_to_reactive(self, page: Page):
|
||||
"""Click Reactive Islands from Geography — content must render."""
|
||||
errors = []
|
||||
page.on("pageerror", lambda err: errors.append(f"UNCAUGHT: {err.message}"))
|
||||
nav(page, "(geography)")
|
||||
ri_link = page.locator("a[sx-push-url]:has-text('Reactive Islands')").first
|
||||
expect(ri_link).to_be_visible(timeout=10000)
|
||||
ri_link.click()
|
||||
page.wait_for_timeout(5000)
|
||||
expect(page.locator("#main-panel")).to_contain_text("Reactive Islands", timeout=10000)
|
||||
fatal = [e for e in errors if "Not callable" in e or "Undefined symbol" in e or "UNCAUGHT" in e]
|
||||
assert not fatal, f"JS errors after navigation: {fatal}"
|
||||
|
||||
def test_direct_load_reactive_page(self, page: Page):
|
||||
"""Direct load of reactive islands page — no errors, content renders."""
|
||||
errors = []
|
||||
page.on("pageerror", lambda err: errors.append(f"UNCAUGHT: {err.message}"))
|
||||
page.on("console", lambda msg: errors.append(msg.text) if msg.type == "error" else None)
|
||||
page.goto(f"{BASE}/sx/(geography.(reactive))", wait_until="networkidle")
|
||||
# aser_slot may take several seconds on first load — wait for render
|
||||
page.wait_for_selector("#main-panel", timeout=30000)
|
||||
expect(page.locator("#main-panel")).to_contain_text("Reactive Islands", timeout=10000)
|
||||
fatal = [e for e in errors if "Not callable" in e or "Undefined symbol" in e or "SES_UNCAUGHT" in e or "UNCAUGHT" in e]
|
||||
assert not fatal, f"JS errors on reactive page: {fatal}"
|
||||
|
||||
|
||||
class TestDocPages:
|
||||
@pytest.mark.parametrize("path,expected", [
|
||||
("(geography.(reactive))", "Reactive Islands"),
|
||||
|
||||
Reference in New Issue
Block a user