Page helpers demo: defisland, map-in-children fix, _eval_slot ref evaluator

- Add page-helpers-demo page with defisland ~demo-client-runner (pure SX,
  zero JS files) showing spec functions running on both server and client
- Fix _aser_component children serialization: flatten list results from map
  instead of serialize(list) which wraps in parens creating ((div ...) ...)
  that re-parses as invalid function call. Fixed in adapter-async.sx spec
  and async_eval_ref.py
- Switch _eval_slot to use async_eval_ref.py when SX_USE_REF=1 (was
  hardcoded to async_eval.py)
- Add Island type support to async_eval_ref.py: import, SSR rendering,
  aser dispatch, thread-first, defisland in _ASER_FORMS
- Add server affinity check: components with :affinity :server expand
  even when _expand_components is False
- Add diagnostic _aser_stack context to EvalError messages
- New spec files: adapter-async.sx, page-helpers.sx, platform_js.py
- Bootstrappers: page-helpers module support, performance.now() timing
- 0-arity lambda event handler fix in adapter-dom.sx

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 14:30:12 +00:00
parent 29c90a625b
commit c95e19dcf2
16 changed files with 5584 additions and 781 deletions

View File

@@ -26,7 +26,7 @@ import contextvars
import inspect
from typing import Any
from ..types import Component, Keyword, Lambda, Macro, NIL, Symbol
from ..types import Component, Island, Keyword, Lambda, Macro, NIL, Symbol
from ..parser import SxExpr, serialize
from ..primitives_io import IO_PRIMITIVES, RequestContext, execute_io
from ..html import (
@@ -210,9 +210,11 @@ async def _arender_list(expr, env, ctx):
if name in HTML_TAGS:
return await _arender_element(name, expr[1:], env, ctx)
# Component
# Component / Island
if name.startswith("~"):
val = env.get(name)
if isinstance(val, Island):
return sx_ref.render_html_island(val, expr[1:], env)
if isinstance(val, Component):
return await _arender_component(val, expr[1:], env, ctx)
@@ -455,6 +457,7 @@ _ASYNC_RENDER_FORMS = {
"defcomp": _arsf_define,
"defmacro": _arsf_define,
"defhandler": _arsf_define,
"defisland": _arsf_define,
"map": _arsf_map,
"map-indexed": _arsf_map_indexed,
"filter": _arsf_filter,
@@ -505,6 +508,8 @@ async def _eval_slot_inner(expr, env, ctx):
if isinstance(result, str):
return SxExpr(result)
return SxExpr(serialize(result))
elif isinstance(comp, Island):
pass # Islands serialize as SX for client hydration
result = await _aser(expr, env, ctx)
result = await _maybe_expand_component_result(result, env, ctx)
if isinstance(result, SxExpr):
@@ -530,6 +535,9 @@ async def _maybe_expand_component_result(result, env, ctx):
return result
_aser_stack: list[str] = [] # diagnostic: track expression context
async def _aser(expr, env, ctx):
"""Evaluate for SX wire format — serialize rendering forms, evaluate control flow."""
if isinstance(expr, (int, float, bool)):
@@ -553,7 +561,8 @@ async def _aser(expr, env, ctx):
return False
if name == "nil":
return NIL
raise EvalError(f"Undefined symbol: {name}")
ctx_info = "".join(_aser_stack[-5:]) if _aser_stack else "(top)"
raise EvalError(f"Undefined symbol: {name} [aser context: {ctx_info}]")
if isinstance(expr, Keyword):
return expr.name
@@ -590,7 +599,7 @@ async def _aser(expr, env, ctx):
if name.startswith("html:"):
return await _aser_call(name[5:], expr[1:], env, ctx)
# Component call
# Component / Island call
if name.startswith("~"):
val = env.get(name)
if isinstance(val, Macro):
@@ -598,7 +607,10 @@ async def _aser(expr, env, ctx):
sx_ref.expand_macro(val, expr[1:], env)
)
return await _aser(expanded, env, ctx)
if isinstance(val, Component) and _expand_components.get():
if isinstance(val, Component) and (
_expand_components.get()
or getattr(val, "render_target", None) == "server"
):
return await _aser_component(val, expr[1:], env, ctx)
return await _aser_call(name, expr[1:], env, ctx)
@@ -633,11 +645,11 @@ async def _aser(expr, env, ctx):
if _svg_context.get(False):
return await _aser_call(name, expr[1:], env, ctx)
# Function/lambda call
# Function/lambda call — fallback: evaluate head as callable
fn = await async_eval(head, env, ctx)
args = [await async_eval(a, env, ctx) for a in expr[1:]]
if callable(fn) and not isinstance(fn, (Lambda, Component)):
if callable(fn) and not isinstance(fn, (Lambda, Component, Island)):
result = fn(*args)
if inspect.iscoroutine(result):
return await result
@@ -650,7 +662,9 @@ async def _aser(expr, env, ctx):
return await _aser(fn.body, local, ctx)
if isinstance(fn, Component):
return await _aser_call(f"~{fn.name}", expr[1:], env, ctx)
raise EvalError(f"Not callable: {fn!r}")
if isinstance(fn, Island):
return await _aser_call(f"~{fn.name}", expr[1:], env, ctx)
raise EvalError(f"Not callable in aser: {fn!r} (expr head: {head!r})")
async def _aser_fragment(children, env, ctx):
@@ -669,28 +683,41 @@ async def _aser_fragment(children, env, ctx):
async def _aser_component(comp, args, env, ctx):
kwargs = {}
children = []
i = 0
while i < len(args):
arg = args[i]
if isinstance(arg, Keyword) and i + 1 < len(args):
kwargs[arg.name] = await _aser(args[i + 1], env, ctx)
i += 2
else:
children.append(arg)
i += 1
local = dict(comp.closure)
local.update(env)
for p in comp.params:
local[p] = kwargs.get(p, NIL)
if comp.has_children:
child_parts = [serialize(await _aser(c, env, ctx)) for c in children]
local["children"] = SxExpr("(<> " + " ".join(child_parts) + ")")
return await _aser(comp.body, local, ctx)
_aser_stack.append(f"~{comp.name}")
try:
kwargs = {}
children = []
i = 0
while i < len(args):
arg = args[i]
if isinstance(arg, Keyword) and i + 1 < len(args):
kwargs[arg.name] = await _aser(args[i + 1], env, ctx)
i += 2
else:
children.append(arg)
i += 1
local = dict(comp.closure)
local.update(env)
for p in comp.params:
local[p] = kwargs.get(p, NIL)
if comp.has_children:
child_parts = []
for c in children:
result = await _aser(c, env, ctx)
if isinstance(result, list):
for item in result:
if item is not NIL and item is not None:
child_parts.append(serialize(item))
elif result is not NIL and result is not None:
child_parts.append(serialize(result))
local["children"] = SxExpr("(<> " + " ".join(child_parts) + ")")
return await _aser(comp.body, local, ctx)
finally:
_aser_stack.pop()
async def _aser_call(name, args, env, ctx):
_aser_stack.append(name)
token = None
if name in ("svg", "math"):
token = _svg_context.set(True)
@@ -730,6 +757,7 @@ async def _aser_call(name, args, env, ctx):
_merge_class_into_parts(parts, extra_class)
return SxExpr("(" + " ".join(parts) + ")")
finally:
_aser_stack.pop()
if token is not None:
_svg_context.reset(token)
@@ -885,7 +913,7 @@ async def _assf_thread_first(expr, env, ctx):
else:
fn = await async_eval(form, env, ctx)
fn_args = [result]
if callable(fn) and not isinstance(fn, (Lambda, Component)):
if callable(fn) and not isinstance(fn, (Lambda, Component, Island)):
result = fn(*fn_args)
if inspect.iscoroutine(result):
result = await result
@@ -981,6 +1009,7 @@ _ASER_FORMS = {
"defcomp": _assf_define,
"defmacro": _assf_define,
"defhandler": _assf_define,
"defisland": _assf_define,
"begin": _assf_begin,
"do": _assf_begin,
"quote": _assf_quote,