Fix process-bindings scope loss and async-invoke arity, bootstrap async adapter

Two bugs fixed:
1. process-bindings used merge(env) which returns {} for Env objects
   (Env is not a dict subclass). Changed to env-extend in render.sx
   and adapter-async.sx. This caused "Undefined symbol: theme" etc.
2. async-aser-eval-call passed evaled-args list to async-invoke(&rest),
   double-wrapping it. Changed to inline apply + coroutine check.

Also: bootstrap define-async into sx_ref.py (Phase 6), replace ~1000 LOC
hand-written async_eval_ref.py with 24-line thin re-export shim.

Test runner now uses Env (not flat dict) for render envs to catch scope bugs.
8 new regression tests (4 scope chain, 2 native callable arity, 2 render).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 16:38:47 +00:00
parent e843602ac9
commit ff6c1fab71
17 changed files with 1402 additions and 1203 deletions

View File

@@ -14,7 +14,7 @@
// ========================================================================= // =========================================================================
var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } }); var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } });
var SX_VERSION = "2026-03-11T14:54:55Z"; var SX_VERSION = "2026-03-11T16:35:21Z";
function isNil(x) { return x === NIL || x === null || x === undefined; } function isNil(x) { return x === NIL || x === null || x === undefined; }
function isSxTruthy(x) { return x !== false && !isNil(x); } function isSxTruthy(x) { return x !== false && !isNil(x); }
@@ -204,7 +204,7 @@
// JSON / dict helpers for island state serialization // JSON / dict helpers for island state serialization
function jsonSerialize(obj) { function jsonSerialize(obj) {
try { return JSON.stringify(obj); } catch(e) { return "{}"; } return JSON.stringify(obj);
} }
function isEmptyDict(d) { function isEmptyDict(d) {
if (!d || typeof d !== "object") return true; if (!d || typeof d !== "object") return true;
@@ -214,11 +214,34 @@
function envHas(env, name) { return name in env; } function envHas(env, name) { return name in env; }
function envGet(env, name) { return env[name]; } function envGet(env, name) { return env[name]; }
function envSet(env, name, val) { env[name] = val; } function envSet(env, name, val) {
// Walk prototype chain to find where the variable is defined (for set!)
var obj = env;
while (obj !== null && obj !== Object.prototype) {
if (obj.hasOwnProperty(name)) { obj[name] = val; return; }
obj = Object.getPrototypeOf(obj);
}
// Not found in any parent scope — set on the immediate env
env[name] = val;
}
function envExtend(env) { return Object.create(env); } function envExtend(env) { return Object.create(env); }
function envMerge(base, overlay) { function envMerge(base, overlay) {
// Same env or overlay is descendant of base — just extend, no copy.
// This prevents set! inside lambdas from modifying shadow copies.
if (base === overlay) return Object.create(base);
var p = overlay;
for (var d = 0; p && p !== Object.prototype && d < 100; d++) {
if (p === base) return Object.create(base);
p = Object.getPrototypeOf(p);
}
// General case: extend base, copy ONLY overlay properties that don't
// exist in the base chain (avoids shadowing closure bindings).
var child = Object.create(base); var child = Object.create(base);
if (overlay) for (var k in overlay) if (overlay.hasOwnProperty(k)) child[k] = overlay[k]; if (overlay) {
for (var k in overlay) {
if (overlay.hasOwnProperty(k) && !(k in base)) child[k] = overlay[k];
}
}
return child; return child;
} }
@@ -732,9 +755,9 @@
var kwargs = first(parsed); var kwargs = first(parsed);
var children = nth(parsed, 1); var children = nth(parsed, 1);
var local = envMerge(componentClosure(comp), env); var local = envMerge(componentClosure(comp), env);
{ var _c = componentParams(comp); for (var _i = 0; _i < _c.length; _i++) { var p = _c[_i]; local[p] = sxOr(dictGet(kwargs, p), NIL); } } { var _c = componentParams(comp); for (var _i = 0; _i < _c.length; _i++) { var p = _c[_i]; envSet(local, p, sxOr(dictGet(kwargs, p), NIL)); } }
if (isSxTruthy(componentHasChildren(comp))) { if (isSxTruthy(componentHasChildren(comp))) {
local["children"] = children; envSet(local, "children", children);
} }
return makeThunk(componentBody(comp), local); return makeThunk(componentBody(comp), local);
})(); }; })(); };
@@ -841,7 +864,7 @@ return append_b(inits, nth(binding, 1)); }, bindings) : reduce(function(acc, pai
var loopBody = (isSxTruthy((len(body) == 1)) ? first(body) : cons(makeSymbol("begin"), body)); var loopBody = (isSxTruthy((len(body) == 1)) ? first(body) : cons(makeSymbol("begin"), body));
var loopFn = makeLambda(params, loopBody, env); var loopFn = makeLambda(params, loopBody, env);
loopFn.name = loopName; loopFn.name = loopName;
lambdaClosure(loopFn)[loopName] = loopFn; envSet(lambdaClosure(loopFn), loopName, loopFn);
return (function() { return (function() {
var initVals = map(function(e) { return trampoline(evalExpr(e, env)); }, inits); var initVals = map(function(e) { return trampoline(evalExpr(e, env)); }, inits);
return callLambda(loopFn, initVals, env); return callLambda(loopFn, initVals, env);
@@ -865,7 +888,7 @@ return append_b(inits, nth(binding, 1)); }, bindings) : reduce(function(acc, pai
if (isSxTruthy((isSxTruthy(isLambda(value)) && isNil(lambdaName(value))))) { if (isSxTruthy((isSxTruthy(isLambda(value)) && isNil(lambdaName(value))))) {
value.name = symbolName(nameSym); value.name = symbolName(nameSym);
} }
env[symbolName(nameSym)] = value; envSet(env, symbolName(nameSym), value);
return value; return value;
})(); }; })(); };
@@ -881,7 +904,7 @@ return append_b(inits, nth(binding, 1)); }, bindings) : reduce(function(acc, pai
var affinity = defcompKwarg(args, "affinity", "auto"); var affinity = defcompKwarg(args, "affinity", "auto");
return (function() { return (function() {
var comp = makeComponent(compName, params, hasChildren, body, env, affinity); var comp = makeComponent(compName, params, hasChildren, body, env, affinity);
env[symbolName(nameSym)] = comp; envSet(env, symbolName(nameSym), comp);
return comp; return comp;
})(); })();
})(); }; })(); };
@@ -924,7 +947,7 @@ return append_b(inits, nth(binding, 1)); }, bindings) : reduce(function(acc, pai
var hasChildren = nth(parsed, 1); var hasChildren = nth(parsed, 1);
return (function() { return (function() {
var island = makeIsland(compName, params, hasChildren, body, env); var island = makeIsland(compName, params, hasChildren, body, env);
env[symbolName(nameSym)] = island; envSet(env, symbolName(nameSym), island);
return island; return island;
})(); })();
})(); }; })(); };
@@ -939,7 +962,7 @@ return append_b(inits, nth(binding, 1)); }, bindings) : reduce(function(acc, pai
var restParam = nth(parsed, 1); var restParam = nth(parsed, 1);
return (function() { return (function() {
var mac = makeMacro(params, restParam, body, env, symbolName(nameSym)); var mac = makeMacro(params, restParam, body, env, symbolName(nameSym));
env[symbolName(nameSym)] = mac; envSet(env, symbolName(nameSym), mac);
return mac; return mac;
})(); })();
})(); }; })(); };
@@ -956,7 +979,7 @@ return append_b(inits, nth(binding, 1)); }, bindings) : reduce(function(acc, pai
var sfDefstyle = function(args, env) { return (function() { var sfDefstyle = function(args, env) { return (function() {
var nameSym = first(args); var nameSym = first(args);
var value = trampoline(evalExpr(nth(args, 1), env)); var value = trampoline(evalExpr(nth(args, 1), env));
env[symbolName(nameSym)] = value; envSet(env, symbolName(nameSym), value);
return value; return value;
})(); }; })(); };
@@ -996,7 +1019,7 @@ return append_b(inits, nth(binding, 1)); }, bindings) : reduce(function(acc, pai
var sfSetBang = function(args, env) { return (function() { var sfSetBang = function(args, env) { return (function() {
var name = symbolName(first(args)); var name = symbolName(first(args));
var value = trampoline(evalExpr(nth(args, 1), env)); var value = trampoline(evalExpr(nth(args, 1), env));
env[name] = value; envSet(env, name, value);
return value; return value;
})(); }; })(); };
@@ -1021,7 +1044,7 @@ return append_b(inits, nth(binding, 1)); }, bindings) : reduce(function(acc, pai
})(); }, NIL, range(0, (len(bindings) / 2)))); })(); }, NIL, range(0, (len(bindings) / 2))));
(function() { (function() {
var values = map(function(e) { return trampoline(evalExpr(e, local)); }, valExprs); var values = map(function(e) { return trampoline(evalExpr(e, local)); }, valExprs);
{ var _c = zip(names, values); for (var _i = 0; _i < _c.length; _i++) { var pair = _c[_i]; local[first(pair)] = nth(pair, 1); } } { var _c = zip(names, values); for (var _i = 0; _i < _c.length; _i++) { var pair = _c[_i]; envSet(local, first(pair), nth(pair, 1)); } }
return forEach(function(val) { return (isSxTruthy(isLambda(val)) ? forEach(function(n) { return envSet(lambdaClosure(val), n, envGet(local, n)); }, names) : NIL); }, values); return forEach(function(val) { return (isSxTruthy(isLambda(val)) ? forEach(function(n) { return envSet(lambdaClosure(val), n, envGet(local, n)); }, names) : NIL); }, values);
})(); })();
{ var _c = slice(body, 0, (len(body) - 1)); for (var _i = 0; _i < _c.length; _i++) { var e = _c[_i]; trampoline(evalExpr(e, local)); } } { var _c = slice(body, 0, (len(body) - 1)); for (var _i = 0; _i < _c.length; _i++) { var e = _c[_i]; trampoline(evalExpr(e, local)); } }
@@ -1046,9 +1069,9 @@ return append_b(inits, nth(binding, 1)); }, bindings) : reduce(function(acc, pai
// expand-macro // expand-macro
var expandMacro = function(mac, rawArgs, env) { return (function() { var expandMacro = function(mac, rawArgs, env) { return (function() {
var local = envMerge(macroClosure(mac), env); var local = envMerge(macroClosure(mac), env);
{ var _c = mapIndexed(function(i, p) { return [p, i]; }, macroParams(mac)); for (var _i = 0; _i < _c.length; _i++) { var pair = _c[_i]; local[first(pair)] = (isSxTruthy((nth(pair, 1) < len(rawArgs))) ? nth(rawArgs, nth(pair, 1)) : NIL); } } { var _c = mapIndexed(function(i, p) { return [p, i]; }, macroParams(mac)); for (var _i = 0; _i < _c.length; _i++) { var pair = _c[_i]; envSet(local, first(pair), (isSxTruthy((nth(pair, 1) < len(rawArgs))) ? nth(rawArgs, nth(pair, 1)) : NIL)); } }
if (isSxTruthy(macroRestParam(mac))) { if (isSxTruthy(macroRestParam(mac))) {
local[macroRestParam(mac)] = slice(rawArgs, len(macroParams(mac))); envSet(local, macroRestParam(mac), slice(rawArgs, len(macroParams(mac))));
} }
return trampoline(evalExpr(macroBody(mac), local)); return trampoline(evalExpr(macroBody(mac), local));
})(); }; })(); };
@@ -1162,7 +1185,7 @@ return append_b(inits, nth(binding, 1)); }, bindings) : reduce(function(acc, pai
// process-bindings // process-bindings
var processBindings = function(bindings, env) { return (function() { var processBindings = function(bindings, env) { return (function() {
var local = merge(env); var local = envExtend(env);
{ var _c = bindings; for (var _i = 0; _i < _c.length; _i++) { var pair = _c[_i]; if (isSxTruthy((isSxTruthy((typeOf(pair) == "list")) && (len(pair) >= 2)))) { { var _c = bindings; for (var _i = 0; _i < _c.length; _i++) { var pair = _c[_i]; if (isSxTruthy((isSxTruthy((typeOf(pair) == "list")) && (len(pair) >= 2)))) {
(function() { (function() {
var name = (isSxTruthy((typeOf(first(pair)) == "symbol")) ? symbolName(first(pair)) : (String(first(pair)))); var name = (isSxTruthy((typeOf(first(pair)) == "symbol")) ? symbolName(first(pair)) : (String(first(pair))));
@@ -1392,9 +1415,9 @@ return (function() { var _m = typeOf(expr); if (_m == "nil") return ""; if (_m =
})(); }, {["i"]: 0, ["skip"]: false}, args); })(); }, {["i"]: 0, ["skip"]: false}, args);
return (function() { return (function() {
var local = envMerge(componentClosure(comp), env); var local = envMerge(componentClosure(comp), env);
{ var _c = componentParams(comp); for (var _i = 0; _i < _c.length; _i++) { var p = _c[_i]; local[p] = (isSxTruthy(dictHas(kwargs, p)) ? dictGet(kwargs, p) : NIL); } } { var _c = componentParams(comp); for (var _i = 0; _i < _c.length; _i++) { var p = _c[_i]; envSet(local, p, (isSxTruthy(dictHas(kwargs, p)) ? dictGet(kwargs, p) : NIL)); } }
if (isSxTruthy(componentHasChildren(comp))) { if (isSxTruthy(componentHasChildren(comp))) {
local["children"] = makeRawHtml(join("", map(function(c) { return renderToHtml(c, env); }, children))); envSet(local, "children", makeRawHtml(join("", map(function(c) { return renderToHtml(c, env); }, children))));
} }
return renderToHtml(componentBody(comp), local); return renderToHtml(componentBody(comp), local);
})(); })();
@@ -1458,20 +1481,20 @@ return (function() { var _m = typeOf(expr); if (_m == "nil") return ""; if (_m =
return (function() { return (function() {
var local = envMerge(componentClosure(island), env); var local = envMerge(componentClosure(island), env);
var islandName = componentName(island); var islandName = componentName(island);
{ var _c = componentParams(island); for (var _i = 0; _i < _c.length; _i++) { var p = _c[_i]; local[p] = (isSxTruthy(dictHas(kwargs, p)) ? dictGet(kwargs, p) : NIL); } } { var _c = componentParams(island); for (var _i = 0; _i < _c.length; _i++) { var p = _c[_i]; envSet(local, p, (isSxTruthy(dictHas(kwargs, p)) ? dictGet(kwargs, p) : NIL)); } }
if (isSxTruthy(componentHasChildren(island))) { if (isSxTruthy(componentHasChildren(island))) {
local["children"] = makeRawHtml(join("", map(function(c) { return renderToHtml(c, env); }, children))); envSet(local, "children", makeRawHtml(join("", map(function(c) { return renderToHtml(c, env); }, children))));
} }
return (function() { return (function() {
var bodyHtml = renderToHtml(componentBody(island), local); var bodyHtml = renderToHtml(componentBody(island), local);
var stateJson = serializeIslandState(kwargs); var stateSx = serializeIslandState(kwargs);
return (String("<span data-sx-island=\"") + String(escapeAttr(islandName)) + String("\"") + String((isSxTruthy(stateJson) ? (String(" data-sx-state=\"") + String(escapeAttr(stateJson)) + String("\"")) : "")) + String(">") + String(bodyHtml) + String("</span>")); return (String("<span data-sx-island=\"") + String(escapeAttr(islandName)) + String("\"") + String((isSxTruthy(stateSx) ? (String(" data-sx-state=\"") + String(escapeAttr(stateSx)) + String("\"")) : "")) + String(">") + String(bodyHtml) + String("</span>"));
})(); })();
})(); })();
})(); }; })(); };
// serialize-island-state // serialize-island-state
var serializeIslandState = function(kwargs) { return (isSxTruthy(isEmptyDict(kwargs)) ? NIL : jsonSerialize(kwargs)); }; var serializeIslandState = function(kwargs) { return (isSxTruthy(isEmptyDict(kwargs)) ? NIL : sxSerialize(kwargs)); };
// === Transpiled from adapter-sx === // === Transpiled from adapter-sx ===
@@ -1586,7 +1609,7 @@ return result; }, args);
var coll = trampoline(evalExpr(nth(args, 1), env)); var coll = trampoline(evalExpr(nth(args, 1), env));
return map(function(item) { return (isSxTruthy(isLambda(f)) ? (function() { return map(function(item) { return (isSxTruthy(isLambda(f)) ? (function() {
var local = envMerge(lambdaClosure(f), env); var local = envMerge(lambdaClosure(f), env);
local[first(lambdaParams(f))] = item; envSet(local, first(lambdaParams(f)), item);
return aser(lambdaBody(f), local); return aser(lambdaBody(f), local);
})() : invoke(f, item)); }, coll); })() : invoke(f, item)); }, coll);
})() : (isSxTruthy((name == "map-indexed")) ? (function() { })() : (isSxTruthy((name == "map-indexed")) ? (function() {
@@ -1594,8 +1617,8 @@ return result; }, args);
var coll = trampoline(evalExpr(nth(args, 1), env)); var coll = trampoline(evalExpr(nth(args, 1), env));
return mapIndexed(function(i, item) { return (isSxTruthy(isLambda(f)) ? (function() { return mapIndexed(function(i, item) { return (isSxTruthy(isLambda(f)) ? (function() {
var local = envMerge(lambdaClosure(f), env); var local = envMerge(lambdaClosure(f), env);
local[first(lambdaParams(f))] = i; envSet(local, first(lambdaParams(f)), i);
local[nth(lambdaParams(f), 1)] = item; envSet(local, nth(lambdaParams(f), 1), item);
return aser(lambdaBody(f), local); return aser(lambdaBody(f), local);
})() : invoke(f, i, item)); }, coll); })() : invoke(f, i, item)); }, coll);
})() : (isSxTruthy((name == "for-each")) ? (function() { })() : (isSxTruthy((name == "for-each")) ? (function() {
@@ -1604,7 +1627,7 @@ return result; }, args);
var results = []; var results = [];
{ var _c = coll; for (var _i = 0; _i < _c.length; _i++) { var item = _c[_i]; (isSxTruthy(isLambda(f)) ? (function() { { var _c = coll; for (var _i = 0; _i < _c.length; _i++) { var item = _c[_i]; (isSxTruthy(isLambda(f)) ? (function() {
var local = envMerge(lambdaClosure(f), env); var local = envMerge(lambdaClosure(f), env);
local[first(lambdaParams(f))] = item; envSet(local, first(lambdaParams(f)), item);
return append_b(results, aser(lambdaBody(f), local)); return append_b(results, aser(lambdaBody(f), local));
})() : invoke(f, item)); } } })() : invoke(f, item)); } }
return (isSxTruthy(isEmpty(results)) ? NIL : results); return (isSxTruthy(isEmpty(results)) ? NIL : results);
@@ -1662,7 +1685,7 @@ return (function() { var _m = typeOf(expr); if (_m == "nil") return createFragme
var attrExpr = nth(args, (get(state, "i") + 1)); var attrExpr = nth(args, (get(state, "i") + 1));
(isSxTruthy(startsWith(attrName, "on-")) ? (function() { (isSxTruthy(startsWith(attrName, "on-")) ? (function() {
var attrVal = trampoline(evalExpr(attrExpr, env)); var attrVal = trampoline(evalExpr(attrExpr, env));
return (isSxTruthy(isCallable(attrVal)) ? domListen(el, slice(attrName, 3), (isSxTruthy((isSxTruthy(isLambda(attrVal)) && (len(lambdaParams(attrVal)) == 0))) ? function(e) { return callLambda(attrVal, [], lambdaClosure(attrVal)); } : attrVal)) : NIL); return (isSxTruthy(isCallable(attrVal)) ? domListen(el, slice(attrName, 3), attrVal) : NIL);
})() : (isSxTruthy((attrName == "bind")) ? (function() { })() : (isSxTruthy((attrName == "bind")) ? (function() {
var attrVal = trampoline(evalExpr(attrExpr, env)); var attrVal = trampoline(evalExpr(attrExpr, env));
return (isSxTruthy(isSignal(attrVal)) ? bindInput(el, attrVal) : NIL); return (isSxTruthy(isSignal(attrVal)) ? bindInput(el, attrVal) : NIL);
@@ -1696,7 +1719,7 @@ return (function() { var _m = typeOf(expr); if (_m == "nil") return createFragme
})(); }, {["i"]: 0, ["skip"]: false}, args); })(); }, {["i"]: 0, ["skip"]: false}, args);
return (function() { return (function() {
var local = envMerge(componentClosure(comp), env); var local = envMerge(componentClosure(comp), env);
{ var _c = componentParams(comp); for (var _i = 0; _i < _c.length; _i++) { var p = _c[_i]; local[p] = (isSxTruthy(dictHas(kwargs, p)) ? dictGet(kwargs, p) : NIL); } } { var _c = componentParams(comp); for (var _i = 0; _i < _c.length; _i++) { var p = _c[_i]; envSet(local, p, (isSxTruthy(dictHas(kwargs, p)) ? dictGet(kwargs, p) : NIL)); } }
if (isSxTruthy(componentHasChildren(comp))) { if (isSxTruthy(componentHasChildren(comp))) {
(function() { (function() {
var childFrag = createFragment(); var childFrag = createFragment();
@@ -1887,7 +1910,7 @@ return (function() { var _m = typeOf(expr); if (_m == "nil") return createFragme
return (function() { return (function() {
var local = envMerge(componentClosure(island), env); var local = envMerge(componentClosure(island), env);
var islandName = componentName(island); var islandName = componentName(island);
{ var _c = componentParams(island); for (var _i = 0; _i < _c.length; _i++) { var p = _c[_i]; local[p] = (isSxTruthy(dictHas(kwargs, p)) ? dictGet(kwargs, p) : NIL); } } { var _c = componentParams(island); for (var _i = 0; _i < _c.length; _i++) { var p = _c[_i]; envSet(local, p, (isSxTruthy(dictHas(kwargs, p)) ? dictGet(kwargs, p) : NIL)); } }
if (isSxTruthy(componentHasChildren(island))) { if (isSxTruthy(componentHasChildren(island))) {
(function() { (function() {
var childFrag = createFragment(); var childFrag = createFragment();
@@ -3002,9 +3025,9 @@ return postSwap(target); }))) : NIL);
var exprs = sxParse(body); var exprs = sxParse(body);
return domListen(el, eventName, function(e) { return (function() { return domListen(el, eventName, function(e) { return (function() {
var handlerEnv = envExtend({}); var handlerEnv = envExtend({});
handlerEnv["event"] = e; envSet(handlerEnv, "event", e);
handlerEnv["this"] = el; envSet(handlerEnv, "this", el);
handlerEnv["detail"] = eventDetail(e); envSet(handlerEnv, "detail", eventDetail(e));
return forEach(function(expr) { return evalExpr(expr, handlerEnv); }, exprs); return forEach(function(expr) { return evalExpr(expr, handlerEnv); }, exprs);
})(); }); })(); });
})()) : NIL); })()) : NIL);
@@ -3233,17 +3256,17 @@ callExpr.push(dictGet(kwargs, k)); } }
// hydrate-island // hydrate-island
var hydrateIsland = function(el) { return (function() { var hydrateIsland = function(el) { return (function() {
var name = domGetAttr(el, "data-sx-island"); var name = domGetAttr(el, "data-sx-island");
var stateJson = sxOr(domGetAttr(el, "data-sx-state"), "{}"); var stateSx = sxOr(domGetAttr(el, "data-sx-state"), "{}");
return (function() { return (function() {
var compName = (String("~") + String(name)); var compName = (String("~") + String(name));
var env = getRenderEnv(NIL); var env = getRenderEnv(NIL);
return (function() { return (function() {
var comp = envGet(env, compName); var comp = envGet(env, compName);
return (isSxTruthy(!isSxTruthy(sxOr(isComponent(comp), isIsland(comp)))) ? logWarn((String("hydrate-island: unknown island ") + String(compName))) : (function() { return (isSxTruthy(!isSxTruthy(sxOr(isComponent(comp), isIsland(comp)))) ? logWarn((String("hydrate-island: unknown island ") + String(compName))) : (function() {
var kwargs = jsonParse(stateJson); var kwargs = sxOr(first(sxParse(stateSx)), {});
var disposers = []; var disposers = [];
var local = envMerge(componentClosure(comp), env); var local = envMerge(componentClosure(comp), env);
{ var _c = componentParams(comp); for (var _i = 0; _i < _c.length; _i++) { var p = _c[_i]; local[p] = (isSxTruthy(dictHas(kwargs, p)) ? dictGet(kwargs, p) : NIL); } } { var _c = componentParams(comp); for (var _i = 0; _i < _c.length; _i++) { var p = _c[_i]; envSet(local, p, (isSxTruthy(dictHas(kwargs, p)) ? dictGet(kwargs, p) : NIL)); } }
return (function() { return (function() {
var bodyDom = withIslandScope(function(disposable) { return append_b(disposers, disposable); }, function() { return renderToDom(componentBody(comp), local, NIL); }); var bodyDom = withIslandScope(function(disposable) { return append_b(disposers, disposable); }, function() { return renderToDom(componentBody(comp), local, NIL); });
domSetTextContent(el, ""); domSetTextContent(el, "");
@@ -3976,8 +3999,11 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() {
function domListen(el, name, handler) { function domListen(el, name, handler) {
if (!_hasDom || !el) return function() {}; if (!_hasDom || !el) return function() {};
// Wrap SX lambdas from runtime-evaluated island code into native fns // Wrap SX lambdas from runtime-evaluated island code into native fns
// If lambda takes 0 params, call without event arg (convenience for on-click handlers)
var wrapped = isLambda(handler) var wrapped = isLambda(handler)
? function(e) { try { invoke(handler, e); } catch(err) { console.error("[sx-ref] domListen handler error:", name, err); } } ? (lambdaParams(handler).length === 0
? function(e) { try { invoke(handler); } catch(err) { console.error("[sx-ref] domListen handler error:", name, err); } }
: function(e) { try { invoke(handler, e); } catch(err) { console.error("[sx-ref] domListen handler error:", name, err); } })
: handler; : handler;
if (name === "click") logInfo("domListen: click on <" + (el.tagName||"?").toLowerCase() + "> text=" + (el.textContent||"").substring(0,20) + " isLambda=" + isLambda(handler)); if (name === "click") logInfo("domListen: click on <" + (el.tagName||"?").toLowerCase() + "> text=" + (el.textContent||"").substring(0,20) + " isLambda=" + isLambda(handler));
el.addEventListener(name, wrapped); el.addEventListener(name, wrapped);

View File

@@ -414,10 +414,10 @@ def _render_component(comp: Component, args: list, env: dict[str, Any]) -> str:
def _render_island(island: Island, args: list, env: dict[str, Any]) -> str: def _render_island(island: Island, args: list, env: dict[str, Any]) -> str:
"""Render an island as static HTML with hydration attributes. """Render an island as static HTML with hydration attributes.
Produces: <span data-sx-island="name" data-sx-state='{"k":"v",...}'>body HTML</span> Produces: <span data-sx-island="name" data-sx-state="{:k &quot;v&quot;}">body HTML</span>
The client hydrates this into a reactive island. The client hydrates this into a reactive island via sx-parse (not JSON).
""" """
import json as _json from .parser import serialize as _sx_serialize
kwargs: dict[str, Any] = {} kwargs: dict[str, Any] = {}
children: list[Any] = [] children: list[Any] = []
@@ -443,26 +443,13 @@ def _render_island(island: Island, args: list, env: dict[str, Any]) -> str:
body_html = _render(island.body, local) body_html = _render(island.body, local)
# Serialize state for hydration — only keyword args # Serialize state for hydration — SX format (not JSON)
state = {} state_sx = _escape_attr(_sx_serialize(kwargs)) if kwargs else ""
for k, v in kwargs.items():
if isinstance(v, (str, int, float, bool)):
state[k] = v
elif v is NIL or v is None:
state[k] = None
elif isinstance(v, list):
state[k] = v
elif isinstance(v, dict):
state[k] = v
else:
state[k] = str(v)
state_json = _escape_attr(_json.dumps(state, separators=(",", ":"))) if state else ""
island_name = _escape_attr(island.name) island_name = _escape_attr(island.name)
parts = [f'<span data-sx-island="{island_name}"'] parts = [f'<span data-sx-island="{island_name}"']
if state_json: if state_sx:
parts.append(f' data-sx-state="{state_json}"') parts.append(f' data-sx-state="{state_sx}"')
parts.append(">") parts.append(">")
parts.append(body_html) parts.append(body_html)
parts.append("</span>") parts.append("</span>")

View File

@@ -450,7 +450,9 @@
(define-async async-process-bindings (define-async async-process-bindings
(fn (bindings env ctx) (fn (bindings env ctx)
(let ((local (merge env))) ;; env-extend (not merge) — Env is not a dict subclass, so merge()
;; returns an empty dict, losing all parent scope bindings.
(let ((local (env-extend env)))
(if (and (= (type-of bindings) "list") (not (empty? bindings))) (if (and (= (type-of bindings) "list") (not (empty? bindings)))
(if (= (type-of (first bindings)) "list") (if (= (type-of (first bindings)) "list")
;; Scheme-style: ((name val) ...) ;; Scheme-style: ((name val) ...)
@@ -669,7 +671,10 @@
(evaled-args (async-eval-args args env ctx))) (evaled-args (async-eval-args args env ctx)))
(cond (cond
(and (callable? f) (not (lambda? f)) (not (component? f))) (and (callable? f) (not (lambda? f)) (not (component? f)))
(async-invoke f evaled-args) ;; apply directly — async-invoke takes &rest so passing a list
;; would wrap it in another list
(let ((r (apply f evaled-args)))
(if (async-coroutine? r) (async-await! r) r))
(lambda? f) (lambda? f)
(let ((local (env-merge (lambda-closure f) env))) (let ((local (env-merge (lambda-closure f) env)))
(for-each-indexed (for-each-indexed
@@ -1166,7 +1171,9 @@
(define-async async-eval-slot-inner (define-async async-eval-slot-inner
(fn (expr env ctx) (fn (expr env ctx)
(let ((result ;; NOTE: Uses statement-form let + set! to avoid expression-context
;; let (IIFE lambdas) which can't contain await in Python.
(let ((result nil))
(if (and (list? expr) (not (empty? expr))) (if (and (list? expr) (not (empty? expr)))
(let ((head (first expr))) (let ((head (first expr)))
(if (and (= (type-of head) "symbol") (if (and (= (type-of head) "symbol")
@@ -1174,11 +1181,10 @@
(let ((name (symbol-name head)) (let ((name (symbol-name head))
(val (if (env-has? env name) (env-get env name) nil))) (val (if (env-has? env name) (env-get env name) nil)))
(if (component? val) (if (component? val)
(async-aser-component val (rest expr) env ctx) (set! result (async-aser-component val (rest expr) env ctx))
;; Islands and unknown components — fall through to aser (set! result (async-maybe-expand-result (async-aser expr env ctx) env ctx))))
(async-maybe-expand-result (async-aser expr env ctx) env ctx))) (set! result (async-maybe-expand-result (async-aser expr env ctx) env ctx))))
(async-maybe-expand-result (async-aser expr env ctx) env ctx))) (set! result (async-maybe-expand-result (async-aser expr env ctx) env ctx)))
(async-maybe-expand-result (async-aser expr env ctx) env ctx))))
;; Normalize result to SxExpr ;; Normalize result to SxExpr
(if (sx-expr? result) (if (sx-expr? result)
result result

View File

@@ -186,15 +186,10 @@
(attr-expr (nth args (inc (get state "i"))))) (attr-expr (nth args (inc (get state "i")))))
(cond (cond
;; Event handler: evaluate eagerly, bind listener ;; Event handler: evaluate eagerly, bind listener
;; If handler is a 0-arity lambda, wrap to ignore the event arg
(starts-with? attr-name "on-") (starts-with? attr-name "on-")
(let ((attr-val (trampoline (eval-expr attr-expr env)))) (let ((attr-val (trampoline (eval-expr attr-expr env))))
(when (callable? attr-val) (when (callable? attr-val)
(dom-listen el (slice attr-name 3) (dom-listen el (slice attr-name 3) attr-val)))
(if (and (lambda? attr-val)
(= (len (lambda-params attr-val)) 0))
(fn (e) (call-lambda attr-val (list) (lambda-closure attr-val)))
attr-val))))
;; Two-way input binding: :bind signal ;; Two-way input binding: :bind signal
(= attr-name "bind") (= attr-name "bind")
(let ((attr-val (trampoline (eval-expr attr-expr env)))) (let ((attr-val (trampoline (eval-expr attr-expr env))))

View File

@@ -433,11 +433,11 @@
;; Render the island body as HTML ;; Render the island body as HTML
(let ((body-html (render-to-html (component-body island) local)) (let ((body-html (render-to-html (component-body island) local))
(state-json (serialize-island-state kwargs))) (state-sx (serialize-island-state kwargs)))
;; Wrap in container with hydration attributes ;; Wrap in container with hydration attributes
(str "<span data-sx-island=\"" (escape-attr island-name) "\"" (str "<span data-sx-island=\"" (escape-attr island-name) "\""
(if state-json (if state-sx
(str " data-sx-state=\"" (escape-attr state-json) "\"") (str " data-sx-state=\"" (escape-attr state-sx) "\"")
"") "")
">" ">"
body-html body-html
@@ -445,17 +445,17 @@
;; -------------------------------------------------------------------------- ;; --------------------------------------------------------------------------
;; serialize-island-state — serialize kwargs to JSON for hydration ;; serialize-island-state — serialize kwargs to SX for hydration
;; -------------------------------------------------------------------------- ;; --------------------------------------------------------------------------
;; ;;
;; Only serializes simple values (numbers, strings, booleans, nil, lists, dicts). ;; Uses the SX serializer (not JSON) so the client can parse with sx-parse.
;; Functions, components, and other non-serializable values are skipped. ;; Handles all SX types natively: numbers, strings, booleans, nil, lists, dicts.
(define serialize-island-state (define serialize-island-state
(fn (kwargs) (fn (kwargs)
(if (empty-dict? kwargs) (if (empty-dict? kwargs)
nil nil
(json-serialize kwargs)))) (sx-serialize kwargs))))
;; -------------------------------------------------------------------------- ;; --------------------------------------------------------------------------
@@ -476,8 +476,8 @@
;; Raw HTML construction: ;; Raw HTML construction:
;; (make-raw-html s) → wrap string as raw HTML (not double-escaped) ;; (make-raw-html s) → wrap string as raw HTML (not double-escaped)
;; ;;
;; JSON serialization (for island state): ;; Island state serialization:
;; (json-serialize dict) → JSON string ;; (sx-serialize val) → SX source string (from parser.sx)
;; (empty-dict? d) → boolean ;; (empty-dict? d) → boolean
;; (escape-attr s) → HTML attribute escape ;; (escape-attr s) → HTML attribute escape
;; ;;

File diff suppressed because it is too large Load Diff

View File

@@ -344,15 +344,15 @@
(define hydrate-island (define hydrate-island
(fn (el) (fn (el)
(let ((name (dom-get-attr el "data-sx-island")) (let ((name (dom-get-attr el "data-sx-island"))
(state-json (or (dom-get-attr el "data-sx-state") "{}"))) (state-sx (or (dom-get-attr el "data-sx-state") "{}")))
(let ((comp-name (str "~" name)) (let ((comp-name (str "~" name))
(env (get-render-env nil))) (env (get-render-env nil)))
(let ((comp (env-get env comp-name))) (let ((comp (env-get env comp-name)))
(if (not (or (component? comp) (island? comp))) (if (not (or (component? comp) (island? comp)))
(log-warn (str "hydrate-island: unknown island " comp-name)) (log-warn (str "hydrate-island: unknown island " comp-name))
;; Parse state and build keyword args ;; Parse state and build keyword args — SX format, not JSON
(let ((kwargs (json-parse state-json)) (let ((kwargs (or (first (sx-parse state-sx)) {}))
(disposers (list)) (disposers (list))
(local (env-merge (component-closure comp) env))) (local (env-merge (component-closure comp) env)))
@@ -494,8 +494,8 @@
;; (log-info msg) → void (console.log with prefix) ;; (log-info msg) → void (console.log with prefix)
;; (log-parse-error label text err) → void (diagnostic parse error) ;; (log-parse-error label text err) → void (diagnostic parse error)
;; ;;
;; === JSON === ;; === Parsing (island state) ===
;; (json-parse str) → dict/list/value (JSON.parse) ;; (sx-parse str) → list of AST expressions (from parser.sx)
;; ;;
;; === Processing markers === ;; === Processing markers ===
;; (mark-processed! el key) → void ;; (mark-processed! el key) → void

View File

@@ -49,6 +49,8 @@ class PyEmitter:
def __init__(self): def __init__(self):
self.indent = 0 self.indent = 0
self._async_names: set[str] = set() # SX names of define-async functions
self._in_async: bool = False # Currently emitting async def body?
def emit(self, expr) -> str: def emit(self, expr) -> str:
"""Emit a Python expression from an SX AST node.""" """Emit a Python expression from an SX AST node."""
@@ -80,6 +82,8 @@ class PyEmitter:
name = head.name name = head.name
if name == "define": if name == "define":
return self._emit_define(expr, indent) return self._emit_define(expr, indent)
if name == "define-async":
return self._emit_define_async(expr, indent)
if name == "set!": if name == "set!":
return f"{pad}{self._mangle(expr[1].name)} = {self.emit(expr[2])}" return f"{pad}{self._mangle(expr[1].name)} = {self.emit(expr[2])}"
if name == "when": if name == "when":
@@ -275,6 +279,19 @@ class PyEmitter:
"sf-defisland": "sf_defisland", "sf-defisland": "sf_defisland",
# adapter-sx.sx # adapter-sx.sx
"render-to-sx": "render_to_sx", "render-to-sx": "render_to_sx",
# adapter-async.sx platform primitives
"svg-context-set!": "svg_context_set",
"svg-context-reset!": "svg_context_reset",
"css-class-collect!": "css_class_collect",
"is-raw-html?": "is_raw_html",
"async-coroutine?": "is_async_coroutine",
"async-await!": "async_await",
"is-sx-expr?": "is_sx_expr",
"sx-expr?": "is_sx_expr",
"io-primitive?": "io_primitive_p",
"expand-components?": "expand_components_p",
"svg-context?": "svg_context_p",
"make-sx-expr": "make_sx_expr",
"aser": "aser", "aser": "aser",
"eval-case-aser": "eval_case_aser", "eval-case-aser": "eval_case_aser",
"sx-serialize": "sx_serialize", "sx-serialize": "sx_serialize",
@@ -417,6 +434,8 @@ class PyEmitter:
# Regular function call # Regular function call
fn_name = self._mangle(name) fn_name = self._mangle(name)
args = ", ".join(self.emit(x) for x in expr[1:]) args = ", ".join(self.emit(x) for x in expr[1:])
if self._in_async and name in self._async_names:
return f"(await {fn_name}({args}))"
return f"{fn_name}({args})" return f"{fn_name}({args})"
# --- Special form emitters --- # --- Special form emitters ---
@@ -513,7 +532,7 @@ class PyEmitter:
body_parts = expr[2:] body_parts = expr[2:]
lines = [f"{pad}if sx_truthy({cond}):"] lines = [f"{pad}if sx_truthy({cond}):"]
for b in body_parts: for b in body_parts:
lines.append(self.emit_statement(b, indent + 1)) self._emit_stmt_recursive(b, lines, indent + 1)
return "\n".join(lines) return "\n".join(lines)
def _emit_cond(self, expr) -> str: def _emit_cond(self, expr) -> str:
@@ -642,6 +661,16 @@ class PyEmitter:
val = self.emit(val_expr) val = self.emit(val_expr)
return f"{pad}{self._mangle(name)} = {val}" return f"{pad}{self._mangle(name)} = {val}"
def _emit_define_async(self, expr, indent: int = 0) -> str:
"""Emit a define-async form as an async def statement."""
name = expr[1].name if isinstance(expr[1], Symbol) else str(expr[1])
val_expr = expr[2]
if (isinstance(val_expr, list) and val_expr and
isinstance(val_expr[0], Symbol) and val_expr[0].name in ("fn", "lambda")):
return self._emit_define_as_def(name, val_expr, indent, is_async=True)
# Shouldn't happen — define-async should always wrap fn/lambda
return self._emit_define(expr, indent)
def _body_uses_set(self, fn_expr) -> bool: def _body_uses_set(self, fn_expr) -> bool:
"""Check if a fn expression's body (recursively) uses set!.""" """Check if a fn expression's body (recursively) uses set!."""
def _has_set(node): def _has_set(node):
@@ -654,12 +683,16 @@ class PyEmitter:
body = fn_expr[2:] body = fn_expr[2:]
return any(_has_set(b) for b in body) return any(_has_set(b) for b in body)
def _emit_define_as_def(self, name: str, fn_expr, indent: int = 0) -> str: def _emit_define_as_def(self, name: str, fn_expr, indent: int = 0,
is_async: bool = False) -> str:
"""Emit a define with fn value as a proper def statement. """Emit a define with fn value as a proper def statement.
This is used for functions that contain set! — Python closures can't This is used for functions that contain set! — Python closures can't
rebind outer lambda params, so we need proper def + local variables. rebind outer lambda params, so we need proper def + local variables.
Variables mutated by set! from nested lambdas use a _cells dict. Variables mutated by set! from nested lambdas use a _cells dict.
When is_async=True, emits 'async def' and sets _in_async so that
calls to other async functions receive 'await'.
""" """
pad = " " * indent pad = " " * indent
params = fn_expr[1] params = fn_expr[1]
@@ -686,14 +719,19 @@ class PyEmitter:
py_name = self._mangle(name) py_name = self._mangle(name)
# Find set! target variables that are used from nested lambda scopes # Find set! target variables that are used from nested lambda scopes
nested_set_vars = self._find_nested_set_vars(body) nested_set_vars = self._find_nested_set_vars(body)
lines = [f"{pad}def {py_name}({params_str}):"] def_kw = "async def" if is_async else "def"
lines = [f"{pad}{def_kw} {py_name}({params_str}):"]
if nested_set_vars: if nested_set_vars:
lines.append(f"{pad} _cells = {{}}") lines.append(f"{pad} _cells = {{}}")
# Emit body with cell var tracking # Emit body with cell var tracking (and async context if needed)
old_cells = getattr(self, '_current_cell_vars', set()) old_cells = getattr(self, '_current_cell_vars', set())
old_async = self._in_async
self._current_cell_vars = nested_set_vars self._current_cell_vars = nested_set_vars
if is_async:
self._in_async = True
self._emit_body_stmts(body, lines, indent + 1) self._emit_body_stmts(body, lines, indent + 1)
self._current_cell_vars = old_cells self._current_cell_vars = old_cells
self._in_async = old_async
return "\n".join(lines) return "\n".join(lines)
def _find_nested_set_vars(self, body) -> set[str]: def _find_nested_set_vars(self, body) -> set[str]:
@@ -750,7 +788,7 @@ class PyEmitter:
if is_last: if is_last:
self._emit_return_expr(expr, lines, indent) self._emit_return_expr(expr, lines, indent)
else: else:
lines.append(self.emit_statement(expr, indent)) self._emit_stmt_recursive(expr, lines, indent)
def _emit_return_expr(self, expr, lines: list, indent: int) -> None: def _emit_return_expr(self, expr, lines: list, indent: int) -> None:
"""Emit an expression in return position, flattening control flow.""" """Emit an expression in return position, flattening control flow."""
@@ -775,6 +813,11 @@ class PyEmitter:
if name in ("do", "begin"): if name in ("do", "begin"):
self._emit_body_stmts(expr[1:], lines, indent) self._emit_body_stmts(expr[1:], lines, indent)
return return
if name == "for-each":
# for-each in return position: emit as statement, return NIL
lines.append(self._emit_for_each_stmt(expr, indent))
lines.append(f"{pad}return NIL")
return
lines.append(f"{pad}return {self.emit(expr)}") lines.append(f"{pad}return {self.emit(expr)}")
def _emit_if_return(self, expr, lines: list, indent: int) -> None: def _emit_if_return(self, expr, lines: list, indent: int) -> None:
@@ -1034,12 +1077,15 @@ class PyEmitter:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def extract_defines(source: str) -> list[tuple[str, list]]: def extract_defines(source: str) -> list[tuple[str, list]]:
"""Parse .sx source, return list of (name, define-expr) for top-level defines.""" """Parse .sx source, return list of (name, define-expr) for top-level defines.
Extracts both (define ...) and (define-async ...) forms.
"""
exprs = parse_all(source) exprs = parse_all(source)
defines = [] defines = []
for expr in exprs: for expr in exprs:
if isinstance(expr, list) and expr and isinstance(expr[0], Symbol): if isinstance(expr, list) and expr and isinstance(expr[0], Symbol):
if expr[0].name == "define": if expr[0].name in ("define", "define-async"):
name = expr[1].name if isinstance(expr[1], Symbol) else str(expr[1]) name = expr[1].name if isinstance(expr[1], Symbol) else str(expr[1])
defines.append((name, expr)) defines.append((name, expr))
return defines return defines
@@ -1212,6 +1258,28 @@ def compile_ref_to_py(
for name in sorted(spec_mod_set): for name in sorted(spec_mod_set):
sx_files.append(SPEC_MODULES[name]) sx_files.append(SPEC_MODULES[name])
# Pre-scan define-async names (needed before transpilation so emitter
# knows which calls require 'await')
has_async = "async" in adapter_set
if has_async:
async_filename = ADAPTER_FILES["async"][0]
async_filepath = os.path.join(ref_dir, async_filename)
if os.path.exists(async_filepath):
with open(async_filepath) as f:
async_src = f.read()
for aexpr in parse_all(async_src):
if (isinstance(aexpr, list) and aexpr
and isinstance(aexpr[0], Symbol)
and aexpr[0].name == "define-async"):
aname = aexpr[1].name if isinstance(aexpr[1], Symbol) else str(aexpr[1])
emitter._async_names.add(aname)
# Platform async primitives (provided by host, also need await)
emitter._async_names.update({
"async-eval", "execute-io", "async-await!",
})
# Async adapter is transpiled last (after sync adapters)
sx_files.append(ADAPTER_FILES["async"])
all_sections = [] all_sections = []
for filename, label in sx_files: for filename, label in sx_files:
filepath = os.path.join(ref_dir, filename) filepath = os.path.join(ref_dir, filename)
@@ -1248,6 +1316,9 @@ def compile_ref_to_py(
if has_deps: if has_deps:
parts.append(PLATFORM_DEPS_PY) parts.append(PLATFORM_DEPS_PY)
if has_async:
parts.append(PLATFORM_ASYNC_PY)
for label, defines in all_sections: for label, defines in all_sections:
parts.append(f"\n# === Transpiled from {label} ===\n") parts.append(f"\n# === Transpiled from {label} ===\n")
for name, expr in defines: for name, expr in defines:
@@ -1258,7 +1329,7 @@ def compile_ref_to_py(
parts.append(FIXUPS_PY) parts.append(FIXUPS_PY)
if has_continuations: if has_continuations:
parts.append(CONTINUATIONS_PY) parts.append(CONTINUATIONS_PY)
parts.append(public_api_py(has_html, has_sx, has_deps)) parts.append(public_api_py(has_html, has_sx, has_deps, has_async))
return "\n".join(parts) return "\n".join(parts)

View File

@@ -1290,8 +1290,9 @@
(= name "append!") (= name "append!")
(str (js-expr (nth expr 1)) ".push(" (js-expr (nth expr 2)) ");") (str (js-expr (nth expr 1)) ".push(" (js-expr (nth expr 2)) ");")
(= name "env-set!") (= name "env-set!")
(str (js-expr (nth expr 1)) "[" (js-expr (nth expr 2)) (str "envSet(" (js-expr (nth expr 1))
"] = " (js-expr (nth expr 3)) ";") ", " (js-expr (nth expr 2))
", " (js-expr (nth expr 3)) ");")
(= name "set-lambda-name!") (= name "set-lambda-name!")
(str (js-expr (nth expr 1)) ".name = " (js-expr (nth expr 2)) ";") (str (js-expr (nth expr 1)) ".name = " (js-expr (nth expr 2)) ";")
:else :else

View File

@@ -1194,7 +1194,7 @@ PLATFORM_JS_PRE = '''
// JSON / dict helpers for island state serialization // JSON / dict helpers for island state serialization
function jsonSerialize(obj) { function jsonSerialize(obj) {
try { return JSON.stringify(obj); } catch(e) { return "{}"; } return JSON.stringify(obj);
} }
function isEmptyDict(d) { function isEmptyDict(d) {
if (!d || typeof d !== "object") return true; if (!d || typeof d !== "object") return true;
@@ -1204,11 +1204,34 @@ PLATFORM_JS_PRE = '''
function envHas(env, name) { return name in env; } function envHas(env, name) { return name in env; }
function envGet(env, name) { return env[name]; } function envGet(env, name) { return env[name]; }
function envSet(env, name, val) { env[name] = val; } function envSet(env, name, val) {
// Walk prototype chain to find where the variable is defined (for set!)
var obj = env;
while (obj !== null && obj !== Object.prototype) {
if (obj.hasOwnProperty(name)) { obj[name] = val; return; }
obj = Object.getPrototypeOf(obj);
}
// Not found in any parent scope — set on the immediate env
env[name] = val;
}
function envExtend(env) { return Object.create(env); } function envExtend(env) { return Object.create(env); }
function envMerge(base, overlay) { function envMerge(base, overlay) {
// Same env or overlay is descendant of base — just extend, no copy.
// This prevents set! inside lambdas from modifying shadow copies.
if (base === overlay) return Object.create(base);
var p = overlay;
for (var d = 0; p && p !== Object.prototype && d < 100; d++) {
if (p === base) return Object.create(base);
p = Object.getPrototypeOf(p);
}
// General case: extend base, copy ONLY overlay properties that don't
// exist in the base chain (avoids shadowing closure bindings).
var child = Object.create(base); var child = Object.create(base);
if (overlay) for (var k in overlay) if (overlay.hasOwnProperty(k)) child[k] = overlay[k]; if (overlay) {
for (var k in overlay) {
if (overlay.hasOwnProperty(k) && !(k in base)) child[k] = overlay[k];
}
}
return child; return child;
} }
@@ -1649,8 +1672,11 @@ PLATFORM_DOM_JS = """
function domListen(el, name, handler) { function domListen(el, name, handler) {
if (!_hasDom || !el) return function() {}; if (!_hasDom || !el) return function() {};
// Wrap SX lambdas from runtime-evaluated island code into native fns // Wrap SX lambdas from runtime-evaluated island code into native fns
// If lambda takes 0 params, call without event arg (convenience for on-click handlers)
var wrapped = isLambda(handler) var wrapped = isLambda(handler)
? function(e) { try { invoke(handler, e); } catch(err) { console.error("[sx-ref] domListen handler error:", name, err); } } ? (lambdaParams(handler).length === 0
? function(e) { try { invoke(handler); } catch(err) { console.error("[sx-ref] domListen handler error:", name, err); } }
: function(e) { try { invoke(handler, e); } catch(err) { console.error("[sx-ref] domListen handler error:", name, err); } })
: handler; : handler;
if (name === "click") logInfo("domListen: click on <" + (el.tagName||"?").toLowerCase() + "> text=" + (el.textContent||"").substring(0,20) + " isLambda=" + isLambda(handler)); if (name === "click") logInfo("domListen: click on <" + (el.tagName||"?").toLowerCase() + "> text=" + (el.textContent||"").substring(0,20) + " isLambda=" + isLambda(handler));
el.addEventListener(name, wrapped); el.addEventListener(name, wrapped);

View File

@@ -462,10 +462,7 @@ def invoke(f, *args):
def json_serialize(obj): def json_serialize(obj):
import json import json
try:
return json.dumps(obj) return json.dumps(obj)
except (TypeError, ValueError):
return "{}"
def is_empty_dict(d): def is_empty_dict(d):
@@ -1067,10 +1064,19 @@ import inspect
from shared.sx.primitives_io import ( from shared.sx.primitives_io import (
IO_PRIMITIVES, RequestContext, execute_io, IO_PRIMITIVES, RequestContext, execute_io,
css_class_collector as _css_class_collector_cv,
_svg_context as _svg_context_cv,
) )
# Lazy imports to avoid circular dependency (html.py imports sx_ref.py)
_css_class_collector_cv = None
_svg_context_cv = None
def _ensure_html_imports():
global _css_class_collector_cv, _svg_context_cv
if _css_class_collector_cv is None:
from shared.sx.html import css_class_collector, _svg_context
_css_class_collector_cv = css_class_collector
_svg_context_cv = _svg_context
# When True, async_aser expands known components server-side # When True, async_aser expands known components server-side
_expand_components_cv: contextvars.ContextVar[bool] = contextvars.ContextVar( _expand_components_cv: contextvars.ContextVar[bool] = contextvars.ContextVar(
"_expand_components_ref", default=False "_expand_components_ref", default=False
@@ -1094,18 +1100,22 @@ def expand_components_p():
def svg_context_p(): def svg_context_p():
_ensure_html_imports()
return _svg_context_cv.get(False) return _svg_context_cv.get(False)
def svg_context_set(val): def svg_context_set(val):
_ensure_html_imports()
return _svg_context_cv.set(val) return _svg_context_cv.set(val)
def svg_context_reset(token): def svg_context_reset(token):
_ensure_html_imports()
_svg_context_cv.reset(token) _svg_context_cv.reset(token)
def css_class_collect(val): def css_class_collect(val):
_ensure_html_imports()
collector = _css_class_collector_cv.get(None) collector = _css_class_collector_cv.get(None)
if collector is not None: if collector is not None:
collector.update(str(val).split()) collector.update(str(val).split())
@@ -1123,6 +1133,25 @@ def is_sx_expr(x):
return isinstance(x, SxExpr) return isinstance(x, SxExpr)
# Predicate helpers used by adapter-async (these are in PRIMITIVES but
# the bootstrapped code calls them as plain functions)
def string_p(x):
return isinstance(x, str)
def list_p(x):
return isinstance(x, _b_list)
def number_p(x):
return isinstance(x, (int, float)) and not isinstance(x, bool)
def sx_parse(src):
from shared.sx.parser import parse_all
return parse_all(src)
def is_async_coroutine(x): def is_async_coroutine(x):
return inspect.iscoroutine(x) return inspect.iscoroutine(x)
@@ -1199,48 +1228,16 @@ async def async_eval_slot_to_sx(expr, env, ctx=None):
ctx = RequestContext() ctx = RequestContext()
token = _expand_components_cv.set(True) token = _expand_components_cv.set(True)
try: try:
return await _eval_slot_inner(expr, env, ctx) result = await async_eval_slot_inner(expr, env, ctx)
if isinstance(result, SxExpr):
return result
if result is None or result is NIL:
return SxExpr("")
if isinstance(result, str):
return SxExpr(result)
return SxExpr(sx_serialize(result))
finally: finally:
_expand_components_cv.reset(token) _expand_components_cv.reset(token)
async def _eval_slot_inner(expr, env, ctx):
if isinstance(expr, list) and expr:
head = expr[0]
if isinstance(head, Symbol) and head.name.startswith("~"):
comp = env.get(head.name)
if isinstance(comp, Component):
result = await async_aser_component(comp, expr[1:], env, ctx)
if isinstance(result, SxExpr):
return result
if result is None or result is NIL:
return SxExpr("")
if isinstance(result, str):
return SxExpr(result)
return SxExpr(sx_serialize(result))
result = await async_aser(expr, env, ctx)
result = await _maybe_expand_component_result(result, env, ctx)
if isinstance(result, SxExpr):
return result
if result is None or result is NIL:
return SxExpr("")
if isinstance(result, str):
return SxExpr(result)
return SxExpr(sx_serialize(result))
async def _maybe_expand_component_result(result, env, ctx):
raw = None
if isinstance(result, SxExpr):
raw = str(result).strip()
elif isinstance(result, str):
raw = result.strip()
if raw and raw.startswith("(~"):
from shared.sx.parser import parse_all as _pa
parsed = _pa(raw)
if parsed:
return await async_eval_slot_to_sx(parsed[0], env, ctx)
return result
''' '''
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1366,7 +1363,8 @@ aser_special = _aser_special_with_continuations
# Public API generator # Public API generator
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def public_api_py(has_html: bool, has_sx: bool, has_deps: bool = False) -> str: def public_api_py(has_html: bool, has_sx: bool, has_deps: bool = False,
has_async: bool = False) -> str:
lines = [ lines = [
'', '',
'# =========================================================================', '# =========================================================================',
@@ -1421,6 +1419,7 @@ def public_api_py(has_html: bool, has_sx: bool, has_deps: bool = False) -> str:
ADAPTER_FILES = { ADAPTER_FILES = {
"html": ("adapter-html.sx", "adapter-html"), "html": ("adapter-html.sx", "adapter-html"),
"sx": ("adapter-sx.sx", "adapter-sx"), "sx": ("adapter-sx.sx", "adapter-sx"),
"async": ("adapter-async.sx", "adapter-async"),
} }
SPEC_MODULES = { SPEC_MODULES = {

View File

@@ -178,7 +178,9 @@
;; bindings = ((name1 expr1) (name2 expr2) ...) ;; bindings = ((name1 expr1) (name2 expr2) ...)
(define process-bindings (define process-bindings
(fn (bindings env) (fn (bindings env)
(let ((local (merge env))) ;; env-extend (not merge) — Env is not a dict subclass, so merge()
;; returns an empty dict, losing all parent scope bindings.
(let ((local (env-extend env)))
(for-each (for-each
(fn (pair) (fn (pair)
(when (and (= (type-of pair) "list") (>= (len pair) 2)) (when (and (= (type-of pair) "list") (>= (len pair) 2))

File diff suppressed because it is too large Load Diff

View File

@@ -119,6 +119,19 @@
(assert-equal "(p \"hello\")" (assert-equal "(p \"hello\")"
(render-sx "(let ((x \"hello\")) (p x))"))) (render-sx "(let ((x \"hello\")) (p x))")))
(deftest "let preserves outer scope bindings"
;; Regression: process-bindings must preserve parent env scope chain.
;; Using merge() instead of env-extend loses parent scope items.
(assert-equal "(p \"outer\")"
(render-sx "(do (define theme \"outer\") (let ((x 1)) (p theme)))")))
(deftest "nested let preserves outer scope"
(assert-equal "(div (span \"hello\") (span \"world\"))"
(render-sx "(do (define a \"hello\")
(define b \"world\")
(div (let ((x 1)) (span a))
(let ((y 2)) (span b))))")))
(deftest "begin serializes last" (deftest "begin serializes last"
(assert-equal "(p \"last\")" (assert-equal "(p \"last\")"
(render-sx "(begin (p \"first\") (p \"last\"))")))) (render-sx "(begin (p \"first\") (p \"last\"))"))))
@@ -213,6 +226,17 @@
(assert-equal "10" (assert-equal "10"
(render-sx "(do (define double (fn (x) (* x 2))) (double 5))"))) (render-sx "(do (define double (fn (x) (* x 2))) (double 5))")))
(deftest "native callable with multiple args"
;; Regression: async-aser-eval-call passed evaled-args list to
;; async-invoke (&rest), wrapping it in another list. apply(f, [list])
;; calls f(list) instead of f(*list).
(assert-equal "3"
(render-sx "(do (define my-add +) (my-add 1 2))")))
(deftest "native callable with two args via alias"
(assert-equal "hello world"
(render-sx "(do (define my-join str) (my-join \"hello\" \" world\"))")))
(deftest "higher-order: map returns list" (deftest "higher-order: map returns list"
(let ((result (render-sx "(map (fn (x) (+ x 1)) (list 1 2 3))"))) (let ((result (render-sx "(map (fn (x) (+ x 1)) (list 1 2 3))")))
;; map at top level returns a list, not serialized tags ;; map at top level returns a list, not serialized tags

View File

@@ -149,7 +149,20 @@
(deftest "let in render context" (deftest "let in render context"
(assert-equal "<p>hello</p>" (assert-equal "<p>hello</p>"
(render-html "(let ((x \"hello\")) (p x))")))) (render-html "(let ((x \"hello\")) (p x))")))
(deftest "let preserves outer scope bindings"
;; Regression: process-bindings must preserve parent env scope chain.
;; Using merge() on Env objects returns empty dict (Env is not dict subclass).
(assert-equal "<p>outer</p>"
(render-html "(do (define theme \"outer\") (let ((x 1)) (p theme)))")))
(deftest "nested let preserves outer scope"
(assert-equal "<div><span>hello</span><span>world</span></div>"
(render-html "(do (define a \"hello\")
(define b \"world\")
(div (let ((x 1)) (span a))
(let ((y 2)) (span b))))"))))
;; -------------------------------------------------------------------------- ;; --------------------------------------------------------------------------

View File

@@ -127,7 +127,8 @@ def render_html(sx_source):
except ImportError: except ImportError:
raise RuntimeError("render-to-html not available — sx_ref.py not built") raise RuntimeError("render-to-html not available — sx_ref.py not built")
exprs = parse_all(sx_source) exprs = parse_all(sx_source)
render_env = dict(env) # Use Env (not flat dict) so tests exercise the real scope chain path.
render_env = _Env(dict(env))
result = "" result = ""
for expr in exprs: for expr in exprs:
result += _render_to_html(expr, render_env) result += _render_to_html(expr, render_env)
@@ -143,7 +144,9 @@ def render_sx(sx_source):
except ImportError: except ImportError:
raise RuntimeError("aser not available — sx_ref.py not built") raise RuntimeError("aser not available — sx_ref.py not built")
exprs = parse_all(sx_source) exprs = parse_all(sx_source)
render_env = dict(env) # Use Env (not flat dict) so tests exercise the real scope chain path.
# Using dict(env) hides bugs where merge() drops Env parent scopes.
render_env = _Env(dict(env))
result = "" result = ""
for expr in exprs: for expr in exprs:
val = _aser(expr, render_env) val = _aser(expr, render_env)

View File

@@ -42,7 +42,6 @@
(sf-ms (- (now-ms) t1)) (sf-ms (- (now-ms) t1))
(sf-cats {}) (sf-cats {})
(sf-total 0) (sf-total 0)
;; 2. build-reference-data ;; 2. build-reference-data
(t2 (now-ms)) (t2 (now-ms))
(ref-result (build-reference-data "attributes" (ref-result (build-reference-data "attributes"