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,19 +1171,20 @@
(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
(if (and (list? expr) (not (empty? expr))) ;; let (IIFE lambdas) which can't contain await in Python.
(let ((head (first expr))) (let ((result nil))
(if (and (= (type-of head) "symbol") (if (and (list? expr) (not (empty? expr)))
(starts-with? (symbol-name head) "~")) (let ((head (first expr)))
(let ((name (symbol-name head)) (if (and (= (type-of head) "symbol")
(val (if (env-has? env name) (env-get env name) nil))) (starts-with? (symbol-name head) "~"))
(if (component? val) (let ((name (symbol-name head))
(async-aser-component val (rest expr) env ctx) (val (if (env-has? env name) (env-get env name) nil)))
;; Islands and unknown components — fall through to aser (if (component? val)
(async-maybe-expand-result (async-aser expr env ctx) env ctx))) (set! result (async-aser-component val (rest expr) 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))))
(set! result (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 = [
'', '',
'# =========================================================================', '# =========================================================================',
@@ -1419,8 +1417,9 @@ 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"