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:
2026-03-19 14:56:55 +00:00
parent d06de87bca
commit f819fda587
5 changed files with 91 additions and 33 deletions

View File

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