Fix reactive islands client-side navigation and hydration
Three bugs prevented islands from working during SX wire navigation: 1. components_for_request() only bundled Component and Macro defs, not Island defs — client never received defisland definitions during navigation (components_for_page for initial HTML shell was correct). 2. hydrate-island used morph-children which can't transfer addEventListener event handlers from freshly rendered DOM to existing nodes. Changed to clear+append so reactive DOM with live signal subscriptions is inserted directly. 3. asyncRenderToDom (client-side async page eval) checked _component but not _island on ~-prefixed names — islands fell through to generic eval which failed. Now delegates to renderDomIsland. 4. setInterval_/setTimeout_ passed SX Lambda objects directly to native timers. JS coerced them to "[object Object]" and tried to eval as code, causing "missing ] after element list". Added _wrapSxFn to convert SX lambdas to JS functions before passing to timers. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -276,6 +276,10 @@ class PyEmitter:
|
||||
# adapter-sx.sx
|
||||
"render-to-sx": "render_to_sx",
|
||||
"aser": "aser",
|
||||
"eval-case-aser": "eval_case_aser",
|
||||
"sx-serialize": "sx_serialize",
|
||||
"sx-serialize-dict": "sx_serialize_dict",
|
||||
"sx-expr-source": "sx_expr_source",
|
||||
# Primitives that need exact aliases
|
||||
"contains?": "contains_p",
|
||||
"starts-with?": "starts_with_p",
|
||||
@@ -1540,6 +1544,16 @@ def tracking_context_notify_fn(ctx):
|
||||
return ctx.notify_fn if isinstance(ctx, _TrackingContext) else NIL
|
||||
|
||||
|
||||
def invoke(f, *args):
|
||||
"""Call f with args — handles both native callables and SX lambdas.
|
||||
|
||||
In Python, all transpiled lambdas are natively callable, so this is
|
||||
just a direct call. The JS host needs dispatch logic here because
|
||||
SX lambdas from runtime-evaluated code are objects, not functions.
|
||||
"""
|
||||
return f(*args)
|
||||
|
||||
|
||||
def json_serialize(obj):
|
||||
import json
|
||||
try:
|
||||
@@ -1605,17 +1619,8 @@ def dict_delete(d, k):
|
||||
|
||||
|
||||
def is_render_expr(expr):
|
||||
"""Check if expression is an HTML element, component, or fragment."""
|
||||
if not isinstance(expr, list) or not expr:
|
||||
return False
|
||||
h = expr[0]
|
||||
if not isinstance(h, Symbol):
|
||||
return False
|
||||
n = h.name
|
||||
return (n == "<>" or n == "raw!" or
|
||||
n.startswith("~") or n.startswith("html:") or
|
||||
n in HTML_TAGS or
|
||||
("-" in n and len(expr) > 1 and isinstance(expr[1], Keyword)))
|
||||
"""Placeholder — overridden by transpiled version from render.sx."""
|
||||
return False
|
||||
|
||||
|
||||
# Render dispatch -- set by adapter
|
||||
@@ -1657,6 +1662,10 @@ def make_raw_html(s):
|
||||
return _RawHTML(s)
|
||||
|
||||
|
||||
def sx_expr_source(x):
|
||||
return x.source if isinstance(x, SxExpr) else str(x)
|
||||
|
||||
|
||||
class EvalError(Exception):
|
||||
pass
|
||||
|
||||
@@ -1696,7 +1705,12 @@ def escape_string(s):
|
||||
|
||||
|
||||
def serialize(val):
|
||||
"""Serialize an SX value to SX source text."""
|
||||
"""Serialize an SX value to SX source text.
|
||||
|
||||
Note: parser.sx defines sx-serialize with a serialize alias, but parser.sx
|
||||
is only included in JS builds (for client-side parsing). Python builds
|
||||
provide this as a platform function.
|
||||
"""
|
||||
t = type_of(val)
|
||||
if t == "sx-expr":
|
||||
return val.source
|
||||
@@ -1730,179 +1744,26 @@ def serialize(val):
|
||||
return "nil"
|
||||
return str(val)
|
||||
|
||||
# Aliases for transpiled code — parser.sx defines sx-serialize/sx-serialize-dict
|
||||
# but parser.sx is JS-only. Provide aliases so transpiled render.sx works.
|
||||
sx_serialize = serialize
|
||||
sx_serialize_dict = lambda d: serialize(d)
|
||||
|
||||
_SPECIAL_FORM_NAMES = frozenset([
|
||||
"if", "when", "cond", "case", "and", "or",
|
||||
"let", "let*", "lambda", "fn",
|
||||
"define", "defcomp", "defmacro", "defstyle",
|
||||
"defhandler", "defpage", "defquery", "defaction", "defrelation",
|
||||
"begin", "do", "quote", "quasiquote",
|
||||
"->", "set!",
|
||||
])
|
||||
|
||||
_HO_FORM_NAMES = frozenset([
|
||||
"map", "map-indexed", "filter", "reduce",
|
||||
"some", "every?", "for-each",
|
||||
])
|
||||
_SPECIAL_FORM_NAMES = frozenset() # Placeholder — overridden by transpiled adapter-sx.sx
|
||||
_HO_FORM_NAMES = frozenset()
|
||||
|
||||
def is_special_form(name):
|
||||
return name in _SPECIAL_FORM_NAMES
|
||||
"""Placeholder — overridden by transpiled version from adapter-sx.sx."""
|
||||
return False
|
||||
|
||||
def is_ho_form(name):
|
||||
return name in _HO_FORM_NAMES
|
||||
"""Placeholder — overridden by transpiled version from adapter-sx.sx."""
|
||||
return False
|
||||
|
||||
|
||||
def aser_special(name, expr, env):
|
||||
"""Evaluate a special/HO form in aser mode.
|
||||
|
||||
Control flow forms evaluate conditions normally but render branches
|
||||
through aser (serializing tags/components instead of rendering HTML).
|
||||
Definition forms evaluate for side effects and return nil.
|
||||
"""
|
||||
# Control flow — evaluate conditions, aser branches
|
||||
args = expr[1:]
|
||||
if name == "if":
|
||||
cond_val = trampoline(eval_expr(args[0], env))
|
||||
if sx_truthy(cond_val):
|
||||
return aser(args[1], env)
|
||||
return aser(args[2], env) if _b_len(args) > 2 else NIL
|
||||
if name == "when":
|
||||
cond_val = trampoline(eval_expr(args[0], env))
|
||||
if sx_truthy(cond_val):
|
||||
result = NIL
|
||||
for body in args[1:]:
|
||||
result = aser(body, env)
|
||||
return result
|
||||
return NIL
|
||||
if name == "cond":
|
||||
clauses = args
|
||||
if clauses and isinstance(clauses[0], _b_list) and _b_len(clauses[0]) == 2:
|
||||
for clause in clauses:
|
||||
test = clause[0]
|
||||
if isinstance(test, Symbol) and test.name in ("else", ":else"):
|
||||
return aser(clause[1], env)
|
||||
if isinstance(test, Keyword) and test.name == "else":
|
||||
return aser(clause[1], env)
|
||||
if sx_truthy(trampoline(eval_expr(test, env))):
|
||||
return aser(clause[1], env)
|
||||
else:
|
||||
i = 0
|
||||
while i < _b_len(clauses) - 1:
|
||||
test = clauses[i]
|
||||
result = clauses[i + 1]
|
||||
if isinstance(test, Keyword) and test.name == "else":
|
||||
return aser(result, env)
|
||||
if isinstance(test, Symbol) and test.name in (":else", "else"):
|
||||
return aser(result, env)
|
||||
if sx_truthy(trampoline(eval_expr(test, env))):
|
||||
return aser(result, env)
|
||||
i += 2
|
||||
return NIL
|
||||
if name == "case":
|
||||
match_val = trampoline(eval_expr(args[0], env))
|
||||
clauses = args[1:]
|
||||
i = 0
|
||||
while i < _b_len(clauses) - 1:
|
||||
test = clauses[i]
|
||||
result = clauses[i + 1]
|
||||
if isinstance(test, Keyword) and test.name == "else":
|
||||
return aser(result, env)
|
||||
if isinstance(test, Symbol) and test.name in (":else", "else"):
|
||||
return aser(result, env)
|
||||
if match_val == trampoline(eval_expr(test, env)):
|
||||
return aser(result, env)
|
||||
i += 2
|
||||
return NIL
|
||||
if name in ("let", "let*"):
|
||||
bindings = args[0]
|
||||
local = _b_dict(env)
|
||||
if isinstance(bindings, _b_list):
|
||||
if bindings and isinstance(bindings[0], _b_list):
|
||||
for b in bindings:
|
||||
var = b[0]
|
||||
vname = var.name if isinstance(var, Symbol) else var
|
||||
local[vname] = trampoline(eval_expr(b[1], local))
|
||||
else:
|
||||
for i in _b_range(0, _b_len(bindings), 2):
|
||||
var = bindings[i]
|
||||
vname = var.name if isinstance(var, Symbol) else var
|
||||
local[vname] = trampoline(eval_expr(bindings[i + 1], local))
|
||||
result = NIL
|
||||
for body in args[1:]:
|
||||
result = aser(body, local)
|
||||
return result
|
||||
if name in ("begin", "do"):
|
||||
result = NIL
|
||||
for body in args:
|
||||
result = aser(body, env)
|
||||
return result
|
||||
if name == "and":
|
||||
result = True
|
||||
for arg in args:
|
||||
result = trampoline(eval_expr(arg, env))
|
||||
if not sx_truthy(result):
|
||||
return result
|
||||
return result
|
||||
if name == "or":
|
||||
result = False
|
||||
for arg in args:
|
||||
result = trampoline(eval_expr(arg, env))
|
||||
if sx_truthy(result):
|
||||
return result
|
||||
return result
|
||||
# HO forms in aser mode — map/for-each render through aser
|
||||
if name == "map":
|
||||
fn = trampoline(eval_expr(args[0], env))
|
||||
coll = trampoline(eval_expr(args[1], env))
|
||||
results = []
|
||||
for item in coll:
|
||||
if isinstance(fn, Lambda):
|
||||
local = _b_dict(fn.closure)
|
||||
local.update(env)
|
||||
local[fn.params[0]] = item
|
||||
results.append(aser(fn.body, local))
|
||||
elif callable(fn):
|
||||
results.append(fn(item))
|
||||
else:
|
||||
raise EvalError("map requires callable")
|
||||
return results
|
||||
if name == "map-indexed":
|
||||
fn = trampoline(eval_expr(args[0], env))
|
||||
coll = trampoline(eval_expr(args[1], env))
|
||||
results = []
|
||||
for i, item in enumerate(coll):
|
||||
if isinstance(fn, Lambda):
|
||||
local = _b_dict(fn.closure)
|
||||
local.update(env)
|
||||
local[fn.params[0]] = i
|
||||
local[fn.params[1]] = item
|
||||
results.append(aser(fn.body, local))
|
||||
elif callable(fn):
|
||||
results.append(fn(i, item))
|
||||
else:
|
||||
raise EvalError("map-indexed requires callable")
|
||||
return results
|
||||
if name == "for-each":
|
||||
fn = trampoline(eval_expr(args[0], env))
|
||||
coll = trampoline(eval_expr(args[1], env))
|
||||
results = []
|
||||
for item in coll:
|
||||
if isinstance(fn, Lambda):
|
||||
local = _b_dict(fn.closure)
|
||||
local.update(env)
|
||||
local[fn.params[0]] = item
|
||||
results.append(aser(fn.body, local))
|
||||
elif callable(fn):
|
||||
fn(item)
|
||||
return results if results else NIL
|
||||
# Definition forms — evaluate for side effects
|
||||
if name in ("define", "defcomp", "defmacro", "defstyle",
|
||||
"defhandler", "defpage", "defquery", "defaction", "defrelation"):
|
||||
trampoline(eval_expr(expr, env))
|
||||
return NIL
|
||||
# Lambda/fn, quote, quasiquote, set!, -> : evaluate normally
|
||||
result = eval_expr(expr, env)
|
||||
return trampoline(result)
|
||||
"""Placeholder — overridden by transpiled version from adapter-sx.sx."""
|
||||
return trampoline(eval_expr(expr, env))
|
||||
'''
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2193,6 +2054,7 @@ parse_int = PRIMITIVES["parse-int"]
|
||||
upper = PRIMITIVES["upper"]
|
||||
has_key_p = PRIMITIVES["has-key?"]
|
||||
dissoc = PRIMITIVES["dissoc"]
|
||||
index_of = PRIMITIVES["index-of"]
|
||||
'''
|
||||
|
||||
|
||||
@@ -2217,7 +2079,7 @@ PLATFORM_DEPS_PY = (
|
||||
' return list(c.css_classes) if hasattr(c, "css_classes") and c.css_classes else []\n'
|
||||
'\n'
|
||||
'def env_components(env):\n'
|
||||
' """Return list of component/macro names in an environment."""\n'
|
||||
' """Placeholder — overridden by transpiled version from deps.sx."""\n'
|
||||
' return [k for k, v in env.items()\n'
|
||||
' if isinstance(v, (Component, Macro))]\n'
|
||||
'\n'
|
||||
@@ -2287,7 +2149,11 @@ CONTINUATIONS_PY = '''
|
||||
|
||||
_RESET_RESUME = [] # stack of resume values; empty = not resuming
|
||||
|
||||
_SPECIAL_FORM_NAMES = _SPECIAL_FORM_NAMES | frozenset(["reset", "shift"])
|
||||
# Extend the transpiled form name lists with continuation forms
|
||||
if isinstance(SPECIAL_FORM_NAMES, list):
|
||||
SPECIAL_FORM_NAMES.extend(["reset", "shift"])
|
||||
else:
|
||||
_SPECIAL_FORM_NAMES = _SPECIAL_FORM_NAMES | frozenset(["reset", "shift"])
|
||||
|
||||
def sf_reset(args, env):
|
||||
"""(reset body) -- establish a continuation delimiter."""
|
||||
|
||||
Reference in New Issue
Block a user