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:
2026-03-08 15:18:45 +00:00
parent 9a0173419a
commit 189a0258d9
14 changed files with 971 additions and 1001 deletions

View File

@@ -209,6 +209,10 @@ class JSEmitter:
"aser-fragment": "aserFragment",
"aser-call": "aserCall",
"aser-special": "aserSpecial",
"eval-case-aser": "evalCaseAser",
"sx-serialize": "sxSerialize",
"sx-serialize-dict": "sxSerializeDict",
"sx-expr-source": "sxExprSource",
"sf-if": "sfIf",
"sf-when": "sfWhen",
"sf-cond": "sfCond",
@@ -1384,9 +1388,10 @@ ASYNC_IO_JS = '''
return null;
}
// Component
// Component or Island
if (hname.charAt(0) === "~") {
var comp = env[hname];
if (comp && comp._island) return renderDomIsland(comp, expr.slice(1), env, ns);
if (comp && comp._component) return asyncRenderComponent(comp, expr.slice(1), env, ns);
if (comp && comp._macro) {
var expanded = trampoline(expandMacro(comp, expr.slice(1), env));
@@ -2206,6 +2211,8 @@ PRIMITIVES_JS_MODULES: dict[str, str] = {
PRIMITIVES["starts-with?"] = function(s, p) { return String(s).indexOf(p) === 0; };
PRIMITIVES["ends-with?"] = function(s, p) { var str = String(s); return str.indexOf(p, str.length - p.length) !== -1; };
PRIMITIVES["slice"] = function(c, a, b) { return b !== undefined ? c.slice(a, b) : c.slice(a); };
PRIMITIVES["substring"] = function(s, a, b) { return String(s).substring(a, b); };
PRIMITIVES["string-length"] = function(s) { return String(s).length; };
PRIMITIVES["concat"] = function() {
var out = [];
for (var i = 0; i < arguments.length; i++) if (!isNil(arguments[i])) out = out.concat(arguments[i]);
@@ -2422,6 +2429,17 @@ PLATFORM_JS_PRE = '''
function trackingContextAddDep(ctx, s) { if (ctx && ctx.deps.indexOf(s) < 0) ctx.deps.push(s); }
function trackingContextNotifyFn(ctx) { return ctx ? ctx.notifyFn : NIL; }
// invoke — call any callable (native fn or SX lambda) with args.
// Transpiled code emits direct calls f(args) which fail on SX lambdas
// from runtime-evaluated island bodies. invoke dispatches correctly.
function invoke() {
var f = arguments[0];
var args = Array.prototype.slice.call(arguments, 1);
if (isLambda(f)) return trampoline(callLambda(f, args, lambdaClosure(f)));
if (typeof f === 'function') return f.apply(null, args);
return NIL;
}
// JSON / dict helpers for island state serialization
function jsonSerialize(obj) {
try { return JSON.stringify(obj); } catch(e) { return "{}"; }
@@ -2447,17 +2465,8 @@ PLATFORM_JS_PRE = '''
// Render-expression detection — lets the evaluator delegate to the active adapter.
// Matches HTML tags, SVG tags, <>, raw!, ~components, html: prefix, custom elements.
function isRenderExpr(expr) {
if (!Array.isArray(expr) || !expr.length) return false;
var h = expr[0];
if (!h || !h._sym) return false;
var n = h.name;
return !!(n === "<>" || n === "raw!" ||
n.charAt(0) === "~" || n.indexOf("html:") === 0 ||
(typeof HTML_TAGS !== "undefined" && HTML_TAGS.indexOf(n) >= 0) ||
(typeof SVG_TAGS !== "undefined" && SVG_TAGS.indexOf(n) >= 0) ||
(n.indexOf("-") > 0 && expr.length > 1 && expr[1] && expr[1]._kw));
}
// Placeholder — overridden by transpiled version from render.sx
function isRenderExpr(expr) { return false; }
// Render dispatch — call the active adapter's render function.
// Set by each adapter when loaded; defaults to identity (no rendering).
@@ -2522,7 +2531,10 @@ PLATFORM_JS_POST = '''
var range = PRIMITIVES["range"];
function zip(a, b) { var r = []; for (var i = 0; i < Math.min(a.length, b.length); i++) r.push([a[i], b[i]]); return r; }
function append_b(arr, x) { arr.push(x); return arr; }
var apply = function(f, args) { return f.apply(null, args); };
var apply = function(f, args) {
if (isLambda(f)) return trampoline(callLambda(f, args, lambdaClosure(f)));
return f.apply(null, args);
};
// Additional primitive aliases used by adapter/engine transpiled code
var split = PRIMITIVES["split"];
@@ -2541,28 +2553,12 @@ PLATFORM_JS_POST = '''
function escapeAttr(s) { return escapeHtml(s); }
function rawHtmlContent(r) { return r.html; }
function makeRawHtml(s) { return { _raw: true, html: s }; }
function sxExprSource(x) { return x && x.source ? x.source : String(x); }
// Serializer
function serialize(val) {
if (isNil(val)) return "nil";
if (typeof val === "boolean") return val ? "true" : "false";
if (typeof val === "number") return String(val);
if (typeof val === "string") return \'"\' + val.replace(/\\\\/g, "\\\\\\\\").replace(/"/g, \'\\\\"\') + \'"\';
if (isSym(val)) return val.name;
if (isKw(val)) return ":" + val.name;
if (Array.isArray(val)) return "(" + val.map(serialize).join(" ") + ")";
return String(val);
}
function isSpecialForm(n) { return n in {
"if":1,"when":1,"cond":1,"case":1,"and":1,"or":1,"let":1,"let*":1,
"lambda":1,"fn":1,"define":1,"defcomp":1,"defmacro":1,"defstyle":1,
"defhandler":1,"begin":1,"do":1,
"quote":1,"quasiquote":1,"->":1,"set!":1
}; }
function isHoForm(n) { return n in {
"map":1,"map-indexed":1,"filter":1,"reduce":1,"some":1,"every?":1,"for-each":1
}; }
// Placeholders — overridden by transpiled spec from parser.sx / adapter-sx.sx
function serialize(val) { return String(val); }
function isSpecialForm(n) { return false; }
function isHoForm(n) { return false; }
// processBindings and evalCond — now specced in render.sx, bootstrapped above
@@ -2888,8 +2884,12 @@ PLATFORM_DOM_JS = """
function domListen(el, name, handler) {
if (!_hasDom || !el) return function() {};
el.addEventListener(name, handler);
return function() { el.removeEventListener(name, handler); };
// Wrap SX lambdas from runtime-evaluated island code into native fns
var wrapped = isLambda(handler)
? function(e) { invoke(handler, e); }
: handler;
el.addEventListener(name, wrapped);
return function() { el.removeEventListener(name, wrapped); };
}
function eventDetail(e) {
@@ -2930,79 +2930,8 @@ PLATFORM_DOM_JS = """
try { return JSON.parse(s); } catch(e) { return {}; }
}
// =========================================================================
// Performance overrides — replace transpiled spec with imperative JS
// =========================================================================
// Override renderDomComponent: imperative kwarg parsing, no reduce/assoc
renderDomComponent = function(comp, args, env, ns) {
// Parse keyword args imperatively
var kwargs = {};
var children = [];
for (var i = 0; i < args.length; i++) {
var arg = args[i];
if (arg && arg._kw && (i + 1) < args.length) {
kwargs[arg.name] = trampoline(evalExpr(args[i + 1], env));
i++; // skip value
} else {
children.push(arg);
}
}
// Build local env via prototype chain
var local = Object.create(componentClosure(comp));
// Copy caller env own properties
for (var k in env) if (env.hasOwnProperty(k)) local[k] = env[k];
// Bind params
var params = componentParams(comp);
for (var j = 0; j < params.length; j++) {
var p = params[j];
local[p] = p in kwargs ? kwargs[p] : NIL;
}
// Bind children
if (componentHasChildren(comp)) {
var childFrag = document.createDocumentFragment();
for (var c = 0; c < children.length; c++) {
var rendered = renderToDom(children[c], env, ns);
if (rendered) childFrag.appendChild(rendered);
}
local["children"] = childFrag;
}
return renderToDom(componentBody(comp), local, ns);
};
// Override renderDomElement: imperative attr parsing, no reduce/assoc
renderDomElement = function(tag, args, env, ns) {
var newNs = tag === "svg" ? SVG_NS : tag === "math" ? MATH_NS : ns;
var el = domCreateElement(tag, newNs);
var extraClasses = [];
var isVoid = contains(VOID_ELEMENTS, tag);
for (var i = 0; i < args.length; i++) {
var arg = args[i];
if (arg && arg._kw && (i + 1) < args.length) {
var attrName = arg.name;
var attrVal = trampoline(evalExpr(args[i + 1], env));
i++; // skip value
if (isNil(attrVal) || attrVal === false) continue;
if (contains(BOOLEAN_ATTRS, attrName)) {
if (isSxTruthy(attrVal)) el.setAttribute(attrName, "");
} else if (attrVal === true) {
el.setAttribute(attrName, "");
} else {
el.setAttribute(attrName, String(attrVal));
}
} else {
if (!isVoid) {
var child = renderToDom(arg, env, newNs);
if (child) el.appendChild(child);
}
}
}
if (extraClasses.length) {
var existing = el.getAttribute("class") || "";
el.setAttribute("class", (existing ? existing + " " : "") + extraClasses.join(" "));
}
return el;
};
// renderDomComponent and renderDomElement are transpiled from
// adapter-dom.sx — no imperative overrides needed.
"""
PLATFORM_ENGINE_PURE_JS = """
@@ -3124,8 +3053,14 @@ PLATFORM_ORCHESTRATION_JS = """
// --- Timers ---
function setTimeout_(fn, ms) { return setTimeout(fn, ms || 0); }
function setInterval_(fn, ms) { return setInterval(fn, ms || 1000); }
function _wrapSxFn(fn) {
if (fn && fn._lambda) {
return function() { return trampoline(callLambda(fn, [], lambdaClosure(fn))); };
}
return fn;
}
function setTimeout_(fn, ms) { return setTimeout(_wrapSxFn(fn), ms || 0); }
function setInterval_(fn, ms) { return setInterval(_wrapSxFn(fn), ms || 1000); }
function clearTimeout_(id) { clearTimeout(id); }
function clearInterval_(id) { clearInterval(id); }
function requestAnimationFrame_(fn) {
@@ -3651,6 +3586,8 @@ PLATFORM_ORCHESTRATION_JS = """
logInfo("sx:route server " + pathname);
executeRequest(link, { method: "GET", url: liveHref }).then(function() {
try { history.pushState({ sxUrl: liveHref, scrollY: window.scrollY }, "", liveHref); } catch (err) {}
}).catch(function(err) {
logWarn("sx:route server fetch error: " + (err && err.message ? err.message : err));
});
}
});
@@ -4050,7 +3987,7 @@ def fixups_js(has_html, has_sx, has_dom, has_signals=False):
lines.append('''
// Expose signal functions as primitives so runtime-evaluated SX code
// (e.g. island bodies from .sx files) can call them
PRIMITIVES["signal"] = createSignal;
PRIMITIVES["signal"] = signal;
PRIMITIVES["signal?"] = isSignal;
PRIMITIVES["deref"] = deref;
PRIMITIVES["reset!"] = reset_b;
@@ -4058,7 +3995,9 @@ def fixups_js(has_html, has_sx, has_dom, has_signals=False):
PRIMITIVES["computed"] = computed;
PRIMITIVES["effect"] = effect;
PRIMITIVES["batch"] = batch;
PRIMITIVES["dispose"] = dispose;
// Timer primitives for island code
PRIMITIVES["set-interval"] = setInterval_;
PRIMITIVES["clear-interval"] = clearInterval_;
// Reactive DOM helpers for island code
PRIMITIVES["reactive-text"] = reactiveText;
PRIMITIVES["create-text-node"] = createTextNode;