Fix lambda multi-body, reactive island demos, and add React is Hypermedia essay
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled

Lambda multi-body fix: sf-lambda used (nth args 1), dropping all but the first
body expression. Fixed to collect all body expressions and wrap in (begin ...).
This was foundational — every multi-expression lambda in every island silently
dropped expressions after the first.

Reactive islands: fix dom-parent marker timing (first effect run before marker
is in DOM), fix :key eager evaluation, fix error boundary scope isolation,
fix resource/suspense reactive cond tracking, fix inc not available as JS var.

New essay: "React is Hypermedia" — argues that reactive islands are hypermedia
controls whose behavior is specified in SX, not a departure from hypermedia.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 20:00:44 +00:00
parent 06adbdcd59
commit 56589a81b2
12 changed files with 1047 additions and 4010 deletions

View File

@@ -14,7 +14,7 @@
// =========================================================================
var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } });
var SX_VERSION = "2026-03-08T16:54:18Z";
var SX_VERSION = "2026-03-08T19:39:22Z";
function isNil(x) { return x === NIL || x === null || x === undefined; }
function isSxTruthy(x) { return x !== false && !isNil(x); }
@@ -304,6 +304,7 @@
PRIMITIVES["odd?"] = function(n) { return n % 2 !== 0; };
PRIMITIVES["even?"] = function(n) { return n % 2 === 0; };
PRIMITIVES["zero?"] = function(n) { return n === 0; };
PRIMITIVES["boolean?"] = function(x) { return x === true || x === false; };
// core.strings
@@ -326,6 +327,7 @@
PRIMITIVES["slice"] = function(c, a, b) { return b !== undefined ? c.slice(a, b) : c.slice(a); };
PRIMITIVES["substring"] = function(s, a, b) { return String(s).substring(a, b); };
PRIMITIVES["string-length"] = function(s) { return String(s).length; };
PRIMITIVES["string-contains?"] = function(s, sub) { return String(s).indexOf(String(sub)) !== -1; };
PRIMITIVES["concat"] = function() {
var out = [];
for (var i = 0; i < arguments.length; i++) if (!isNil(arguments[i])) out = out.concat(arguments[i]);
@@ -360,6 +362,12 @@
PRIMITIVES["zip-pairs"] = function(c) {
var r = []; for (var i = 0; i < c.length - 1; i++) r.push([c[i], c[i + 1]]); return r;
};
PRIMITIVES["reverse"] = function(c) { return Array.isArray(c) ? c.slice().reverse() : String(c).split("").reverse().join(""); };
PRIMITIVES["flatten"] = function(c) {
var out = [];
function walk(a) { for (var i = 0; i < a.length; i++) Array.isArray(a[i]) ? walk(a[i]) : out.push(a[i]); }
walk(c || []); return out;
};
// core.dict
@@ -381,6 +389,7 @@
return out;
};
PRIMITIVES["dict-set!"] = function(d, k, v) { d[k] = v; return v; };
PRIMITIVES["has-key?"] = function(d, k) { return d !== null && d !== undefined && k in d; };
PRIMITIVES["into"] = function(target, coll) {
if (Array.isArray(target)) return Array.isArray(coll) ? coll.slice() : Object.entries(coll);
var r = {}; for (var i = 0; i < coll.length; i++) { var p = coll[i]; if (Array.isArray(p) && p.length >= 2) r[p[0]] = p[1]; }
@@ -749,7 +758,8 @@ return append_b(inits, nth(binding, 1)); }, bindings) : reduce(function(acc, pai
// sf-lambda
var sfLambda = function(args, env) { return (function() {
var paramsExpr = first(args);
var body = nth(args, 1);
var bodyExprs = rest(args);
var body = (isSxTruthy((len(bodyExprs) == 1)) ? first(bodyExprs) : cons(makeSymbol("begin"), bodyExprs));
var paramNames = map(function(p) { return (isSxTruthy((typeOf(p) == "symbol")) ? symbolName(p) : p); }, paramsExpr);
return makeLambda(paramNames, body, env);
})(); };
@@ -1491,8 +1501,23 @@ return result; }, args);
var skip = get(state, "skip");
return (isSxTruthy(skip) ? assoc(state, "skip", false, "i", (get(state, "i") + 1)) : (isSxTruthy((isSxTruthy((typeOf(arg) == "keyword")) && ((get(state, "i") + 1) < len(args)))) ? (function() {
var attrName = keywordName(arg);
var attrVal = trampoline(evalExpr(nth(args, (get(state, "i") + 1)), env));
(isSxTruthy(sxOr(isNil(attrVal), (attrVal == false))) ? NIL : (isSxTruthy((isSxTruthy(startsWith(attrName, "on-")) && isCallable(attrVal))) ? domListen(el, slice(attrName, 3), attrVal) : (isSxTruthy((isSxTruthy((attrName == "bind")) && isSignal(attrVal))) ? bindInput(el, attrVal) : (isSxTruthy((attrName == "ref")) ? dictSet(attrVal, "current", el) : (isSxTruthy(contains(BOOLEAN_ATTRS, attrName)) ? (isSxTruthy(attrVal) ? domSetAttr(el, attrName, "") : NIL) : (isSxTruthy((attrVal == true)) ? domSetAttr(el, attrName, "") : domSetAttr(el, attrName, (String(attrVal)))))))));
var attrExpr = nth(args, (get(state, "i") + 1));
(isSxTruthy(startsWith(attrName, "on-")) ? (function() {
var attrVal = trampoline(evalExpr(attrExpr, env));
return (isSxTruthy(isCallable(attrVal)) ? domListen(el, slice(attrName, 3), attrVal) : NIL);
})() : (isSxTruthy((attrName == "bind")) ? (function() {
var attrVal = trampoline(evalExpr(attrExpr, env));
return (isSxTruthy(isSignal(attrVal)) ? bindInput(el, attrVal) : NIL);
})() : (isSxTruthy((attrName == "ref")) ? (function() {
var attrVal = trampoline(evalExpr(attrExpr, env));
return dictSet(attrVal, "current", el);
})() : (isSxTruthy((attrName == "key")) ? (function() {
var attrVal = trampoline(evalExpr(attrExpr, env));
return domSetAttr(el, "key", (String(attrVal)));
})() : (isSxTruthy(_islandScope) ? reactiveAttr(el, attrName, function() { return trampoline(evalExpr(attrExpr, env)); }) : (function() {
var attrVal = trampoline(evalExpr(attrExpr, env));
return (isSxTruthy(sxOr(isNil(attrVal), (attrVal == false))) ? NIL : (isSxTruthy(contains(BOOLEAN_ATTRS, attrName)) ? (isSxTruthy(attrVal) ? domSetAttr(el, attrName, "") : NIL) : (isSxTruthy((attrVal == true)) ? domSetAttr(el, attrName, "") : domSetAttr(el, attrName, (String(attrVal))))));
})())))));
return assoc(state, "skip", true, "i", (get(state, "i") + 1));
})() : ((isSxTruthy(!isSxTruthy(contains(VOID_ELEMENTS, tag))) ? domAppend(el, renderToDom(arg, env, newNs)) : NIL), assoc(state, "i", (get(state, "i") + 1)))));
})(); }, {["i"]: 0, ["skip"]: false}, args);
@@ -1552,17 +1577,84 @@ return result; }, args);
var isRenderDomForm = function(name) { return contains(RENDER_DOM_FORMS, name); };
// dispatch-render-form
var dispatchRenderForm = function(name, expr, env, ns) { return (isSxTruthy((name == "if")) ? (function() {
var dispatchRenderForm = function(name, expr, env, ns) { return (isSxTruthy((name == "if")) ? (isSxTruthy(_islandScope) ? (function() {
var marker = createComment("r-if");
var currentNodes = [];
var initialResult = NIL;
effect(function() { return (function() {
var result = (function() {
var condVal = trampoline(evalExpr(nth(expr, 1), env));
return (isSxTruthy(condVal) ? renderToDom(nth(expr, 2), env, ns) : (isSxTruthy((len(expr) > 3)) ? renderToDom(nth(expr, 3), env, ns) : createFragment()));
})() : (isSxTruthy((name == "when")) ? (isSxTruthy(!isSxTruthy(trampoline(evalExpr(nth(expr, 1), env)))) ? createFragment() : (function() {
})();
return (isSxTruthy(domParent(marker)) ? (forEach(function(n) { return domRemove(n); }, currentNodes), (currentNodes = (isSxTruthy(domIsFragment(result)) ? domChildNodes(result) : [result])), domInsertAfter(marker, result)) : (initialResult = result));
})(); });
return (function() {
var frag = createFragment();
domAppend(frag, marker);
if (isSxTruthy(initialResult)) {
currentNodes = (isSxTruthy(domIsFragment(initialResult)) ? domChildNodes(initialResult) : [initialResult]);
domAppend(frag, initialResult);
}
return frag;
})();
})() : (function() {
var condVal = trampoline(evalExpr(nth(expr, 1), env));
return (isSxTruthy(condVal) ? renderToDom(nth(expr, 2), env, ns) : (isSxTruthy((len(expr) > 3)) ? renderToDom(nth(expr, 3), env, ns) : createFragment()));
})()) : (isSxTruthy((name == "when")) ? (isSxTruthy(_islandScope) ? (function() {
var marker = createComment("r-when");
var currentNodes = [];
var initialResult = NIL;
effect(function() { return (isSxTruthy(domParent(marker)) ? (forEach(function(n) { return domRemove(n); }, currentNodes), (currentNodes = []), (isSxTruthy(trampoline(evalExpr(nth(expr, 1), env))) ? (function() {
var frag = createFragment();
{ var _c = range(2, len(expr)); for (var _i = 0; _i < _c.length; _i++) { var i = _c[_i]; domAppend(frag, renderToDom(nth(expr, i), env, ns)); } }
currentNodes = domChildNodes(frag);
return domInsertAfter(marker, frag);
})() : NIL)) : (isSxTruthy(trampoline(evalExpr(nth(expr, 1), env))) ? (function() {
var frag = createFragment();
{ var _c = range(2, len(expr)); for (var _i = 0; _i < _c.length; _i++) { var i = _c[_i]; domAppend(frag, renderToDom(nth(expr, i), env, ns)); } }
currentNodes = domChildNodes(frag);
return (initialResult = frag);
})() : NIL)); });
return (function() {
var frag = createFragment();
domAppend(frag, marker);
if (isSxTruthy(initialResult)) {
domAppend(frag, initialResult);
}
return frag;
})();
})() : (isSxTruthy(!isSxTruthy(trampoline(evalExpr(nth(expr, 1), env)))) ? createFragment() : (function() {
var frag = createFragment();
{ var _c = range(2, len(expr)); for (var _i = 0; _i < _c.length; _i++) { var i = _c[_i]; domAppend(frag, renderToDom(nth(expr, i), env, ns)); } }
return frag;
})()) : (isSxTruthy((name == "cond")) ? (function() {
})())) : (isSxTruthy((name == "cond")) ? (isSxTruthy(_islandScope) ? (function() {
var marker = createComment("r-cond");
var currentNodes = [];
var initialResult = NIL;
effect(function() { return (function() {
var branch = evalCond(rest(expr), env);
return (isSxTruthy(domParent(marker)) ? (forEach(function(n) { return domRemove(n); }, currentNodes), (currentNodes = []), (isSxTruthy(branch) ? (function() {
var result = renderToDom(branch, env, ns);
currentNodes = (isSxTruthy(domIsFragment(result)) ? domChildNodes(result) : [result]);
return domInsertAfter(marker, result);
})() : NIL)) : (isSxTruthy(branch) ? (function() {
var result = renderToDom(branch, env, ns);
currentNodes = (isSxTruthy(domIsFragment(result)) ? domChildNodes(result) : [result]);
return (initialResult = result);
})() : NIL));
})(); });
return (function() {
var frag = createFragment();
domAppend(frag, marker);
if (isSxTruthy(initialResult)) {
domAppend(frag, initialResult);
}
return frag;
})();
})() : (function() {
var branch = evalCond(rest(expr), env);
return (isSxTruthy(branch) ? renderToDom(branch, env, ns) : createFragment());
})() : (isSxTruthy((name == "case")) ? renderToDom(trampoline(evalExpr(expr, env)), env, ns) : (isSxTruthy(sxOr((name == "let"), (name == "let*"))) ? (function() {
})()) : (isSxTruthy((name == "case")) ? renderToDom(trampoline(evalExpr(expr, env)), env, ns) : (isSxTruthy(sxOr((name == "let"), (name == "let*"))) ? (function() {
var local = processBindings(nth(expr, 1), env);
var frag = createFragment();
{ var _c = range(2, len(expr)); for (var _i = 0; _i < _c.length; _i++) { var i = _c[_i]; domAppend(frag, renderToDom(nth(expr, i), local, ns)); } }
@@ -1604,7 +1696,7 @@ return result; }, args);
return domAppend(frag, val);
})(); }, coll);
return frag;
})() : (isSxTruthy((name == "filter")) ? renderToDom(trampoline(evalExpr(expr, env)), env, ns) : (isSxTruthy((name == "portal")) ? renderDomPortal(args, env, ns) : (isSxTruthy((name == "error-boundary")) ? renderDomErrorBoundary(args, env, ns) : (isSxTruthy((name == "for-each")) ? (function() {
})() : (isSxTruthy((name == "filter")) ? renderToDom(trampoline(evalExpr(expr, env)), env, ns) : (isSxTruthy((name == "portal")) ? renderDomPortal(rest(expr), env, ns) : (isSxTruthy((name == "error-boundary")) ? renderDomErrorBoundary(rest(expr), env, ns) : (isSxTruthy((name == "for-each")) ? (function() {
var f = trampoline(evalExpr(nth(expr, 1), env));
var coll = trampoline(evalExpr(nth(expr, 2), env));
var frag = createFragment();
@@ -1706,9 +1798,8 @@ return (isSxTruthy(testFn()) ? (function() {
var keyOrder = [];
domAppend(container, marker);
effect(function() { return (function() {
var parent = domParent(marker);
var items = deref(itemsSig);
return (isSxTruthy(parent) ? (function() {
return (isSxTruthy(domParent(marker)) ? (function() {
var newMap = {};
var newKeys = [];
var hasKeys = false;
@@ -1738,7 +1829,13 @@ return (isSxTruthy(testFn()) ? (function() {
})()));
keyMap = newMap;
return (keyOrder = newKeys);
})() : NIL);
})() : forEachIndexed(function(idx, item) { return (function() {
var rendered = renderListItem(mapFn, item, env, ns);
var key = extractKey(rendered, idx);
keyMap[key] = rendered;
keyOrder.push(key);
return domAppend(container, rendered);
})(); }, items));
})(); });
return container;
})(); };
@@ -1758,8 +1855,8 @@ return (isSxTruthy(testFn()) ? (function() {
// render-dom-portal
var renderDomPortal = function(args, env, ns) { return (function() {
var selector = trampoline(evalExpr(first(args), env));
var target = domQuery(selector);
return (isSxTruthy(!isSxTruthy(target)) ? (logWarn((String("Portal target not found: ") + String(selector))), createComment((String("portal: ") + String(selector) + String(" (not found)")))) : (function() {
var target = sxOr(domQuery(selector), domEnsureElement(selector));
return (isSxTruthy(!isSxTruthy(target)) ? createComment((String("portal: ") + String(selector) + String(" (not found)"))) : (function() {
var marker = createComment((String("portal: ") + String(selector)));
var frag = createFragment();
{ var _c = rest(args); for (var _i = 0; _i < _c.length; _i++) { var child = _c[_i]; domAppend(frag, renderToDom(child, env, ns)); } }
@@ -1777,32 +1874,29 @@ return (isSxTruthy(testFn()) ? (function() {
var fallbackExpr = first(args);
var bodyExprs = rest(args);
var container = domCreateElement("div", NIL);
var boundaryDisposers = [];
var retryVersion = signal(0);
domSetAttr(container, "data-sx-boundary", "true");
return (function() {
var renderBody = function() { { var _c = boundaryDisposers; for (var _i = 0; _i < _c.length; _i++) { var d = _c[_i]; d(); } }
boundaryDisposers = [];
effect(function() { deref(retryVersion);
domSetProp(container, "innerHTML", "");
return tryCatch(function() { return withIslandScope(function(disposable) { boundaryDisposers.push(disposable);
return registerInScope(disposable); }, function() { return (function() {
return (function() {
var savedScope = _islandScope;
_islandScope = NIL;
return tryCatch(function() { (function() {
var frag = createFragment();
{ var _c = bodyExprs; for (var _i = 0; _i < _c.length; _i++) { var child = _c[_i]; domAppend(frag, renderToDom(child, env, ns)); } }
return domAppend(container, frag);
})(); }); }, function(err) { { var _c = boundaryDisposers; for (var _i = 0; _i < _c.length; _i++) { var d = _c[_i]; d(); } }
boundaryDisposers = [];
})();
return (_islandScope = savedScope); }, function(err) { _islandScope = savedScope;
return (function() {
var fallbackFn = trampoline(evalExpr(fallbackExpr, env));
var retryFn = function() { return renderBody(); };
var retryFn = function() { return swap_b(retryVersion, function(n) { return (n + 1); }); };
return (function() {
var fallbackDom = (isSxTruthy(isLambda(fallbackFn)) ? renderLambdaDom(fallbackFn, [err, retryFn], env, ns) : renderToDom(apply(fallbackFn, [err, retryFn]), env, ns));
return domAppend(container, fallbackDom);
})();
})(); }); };
renderBody();
registerInScope(function() { { var _c = boundaryDisposers; for (var _i = 0; _i < _c.length; _i++) { var d = _c[_i]; d(); } }
return (boundaryDisposers = []); });
})(); });
})(); });
return container;
})();
})(); };
@@ -3070,14 +3164,14 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() {
// def-store
var defStore = function(name, initFn) { return (function() {
var registry = _storeRegistry;
if (isSxTruthy(!isSxTruthy(hasKey_p(registry, name)))) {
if (isSxTruthy(!isSxTruthy(dictHas(registry, name)))) {
_storeRegistry = assoc(registry, name, invoke(initFn));
}
return get(_storeRegistry, name);
})(); };
// use-store
var useStore = function(name) { return (isSxTruthy(hasKey_p(_storeRegistry, name)) ? get(_storeRegistry, name) : error((String("Store not found: ") + String(name) + String(". Call (def-store ...) before (use-store ...).")))); };
var useStore = function(name) { return (isSxTruthy(dictHas(_storeRegistry, name)) ? get(_storeRegistry, name) : error((String("Store not found: ") + String(name) + String(". Call (def-store ...) before (use-store ...).")))); };
// clear-stores
var clearStores = function() { return (_storeRegistry = {}); };
@@ -3273,6 +3367,20 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() {
return _hasDom ? document.querySelector(sel) : null;
}
function domEnsureElement(sel) {
if (!_hasDom) return null;
var el = document.querySelector(sel);
if (el) return el;
// Parse #id selector → create div with that id, append to body
if (sel.charAt(0) === '#') {
el = document.createElement('div');
el.id = sel.slice(1);
document.body.appendChild(el);
return el;
}
return null;
}
function domQueryAll(root, sel) {
if (!root || !root.querySelectorAll) return [];
return Array.prototype.slice.call(root.querySelectorAll(sel));
@@ -3404,6 +3512,12 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() {
function promiseCatch(p, fn) { return p && p.catch ? p.catch(fn) : p; }
function promiseDelayed(ms, value) {
return new Promise(function(resolve) {
setTimeout(function() { resolve(value); }, ms);
});
}
// --- Abort controllers ---
var _controllers = typeof WeakMap !== "undefined" ? new WeakMap() : null;
@@ -3440,8 +3554,9 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() {
function clearTimeout_(id) { clearTimeout(id); }
function clearInterval_(id) { clearInterval(id); }
function requestAnimationFrame_(fn) {
if (typeof requestAnimationFrame !== "undefined") requestAnimationFrame(fn);
else setTimeout(fn, 16);
var cb = _wrapSxFn(fn);
if (typeof requestAnimationFrame !== "undefined") requestAnimationFrame(cb);
else setTimeout(cb, 16);
}
// --- Fetch ---
@@ -3840,14 +3955,19 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() {
function stopPropagation_(e) { if (e && e.stopPropagation) e.stopPropagation(); }
function domFocus(el) { if (el && el.focus) el.focus(); }
function tryCatch(tryFn, catchFn) {
try { return tryFn(); } catch (e) { return catchFn(e); }
var t = _wrapSxFn(tryFn);
var c = catchFn && catchFn._lambda
? function(e) { return trampoline(callLambda(catchFn, [e], lambdaClosure(catchFn))); }
: catchFn;
try { return t(); } catch (e) { return c(e); }
}
function errorMessage(e) {
return e && e.message ? e.message : String(e);
}
function scheduleIdle(fn) {
if (typeof requestIdleCallback !== "undefined") requestIdleCallback(fn);
else setTimeout(fn, 0);
var cb = _wrapSxFn(fn);
if (typeof requestIdleCallback !== "undefined") requestIdleCallback(cb);
else setTimeout(cb, 0);
}
function elementValue(el) { return el && el.value !== undefined ? el.value : NIL; }
@@ -4386,11 +4506,24 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() {
PRIMITIVES["dom-listen"] = domListen;
PRIMITIVES["dom-dispatch"] = domDispatch;
PRIMITIVES["event-detail"] = eventDetail;
PRIMITIVES["resource"] = resource;
PRIMITIVES["promise-delayed"] = promiseDelayed;
PRIMITIVES["promise-then"] = promiseThen;
PRIMITIVES["def-store"] = defStore;
PRIMITIVES["use-store"] = useStore;
PRIMITIVES["emit-event"] = emitEvent;
PRIMITIVES["on-event"] = onEvent;
PRIMITIVES["bridge-event"] = bridgeEvent;
// DOM primitives for island code
PRIMITIVES["dom-focus"] = domFocus;
PRIMITIVES["dom-tag-name"] = domTagName;
PRIMITIVES["dom-get-prop"] = domGetProp;
PRIMITIVES["stop-propagation"] = stopPropagation_;
PRIMITIVES["error-message"] = errorMessage;
PRIMITIVES["schedule-idle"] = scheduleIdle;
PRIMITIVES["invoke"] = invoke;
PRIMITIVES["error"] = function(msg) { throw new Error(msg); };
PRIMITIVES["filter"] = filter;
// =========================================================================
// Async IO: Promise-aware rendering for client-side IO primitives

File diff suppressed because it is too large Load Diff

View File

@@ -313,7 +313,7 @@ async def full_page_sx(ctx: dict, *, header_rows: str,
# Wrap body + meta in a fragment so sx.js renders both;
# auto-hoist moves meta/title/link elements to <head>.
body_sx = _sx_fragment(meta, body_sx)
return sx_page(ctx, body_sx, meta_html=meta_html)
return await sx_page(ctx, body_sx, meta_html=meta_html)
def _build_component_ast(__name: str, **kwargs: Any) -> list:
@@ -620,51 +620,8 @@ def sx_response(source: str, status: int = 200,
# ---------------------------------------------------------------------------
# Sx wire-format full page shell
# ---------------------------------------------------------------------------
_SX_PAGE_TEMPLATE = """\
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="index,follow">
<meta name="theme-color" content="#ffffff">
<title>{title}</title>
{meta_html}
<style>@media (min-width: 768px) {{ .js-mobile-sentinel {{ display:none !important; }} }}</style>
<meta name="csrf-token" content="{csrf}">
<style id="sx-css">{sx_css}</style>
<meta name="sx-css-classes" content="{sx_css_classes}">
<script src="https://unpkg.com/prismjs/prism.js"></script>
<script src="https://unpkg.com/prismjs/components/prism-javascript.min.js"></script>
<script src="https://unpkg.com/prismjs/components/prism-python.min.js"></script>
<script src="https://unpkg.com/prismjs/components/prism-bash.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script>if(matchMedia('(hover:hover) and (pointer:fine)').matches){{document.documentElement.classList.add('hover-capable')}}</script>
<script>document.addEventListener('click',function(e){{var t=e.target.closest('[data-close-details]');if(!t)return;var d=t.closest('details');if(d)d.removeAttribute('open')}})</script>
<style>
details[data-toggle-group="mobile-panels"]>summary{{list-style:none}}
details[data-toggle-group="mobile-panels"]>summary::-webkit-details-marker{{display:none}}
@media(min-width:768px){{.nav-group:focus-within .submenu,.nav-group:hover .submenu{{display:block}}}}
img{{max-width:100%;height:auto}}
.clamp-2{{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}}
.clamp-3{{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}}
.no-scrollbar::-webkit-scrollbar{{display:none}}.no-scrollbar{{-ms-overflow-style:none;scrollbar-width:none}}
details.group{{overflow:hidden}}details.group>summary{{list-style:none}}details.group>summary::-webkit-details-marker{{display:none}}
.sx-indicator{{display:none}}.sx-request .sx-indicator{{display:inline-flex}}
.sx-error .sx-indicator{{display:none}}.sx-loading .sx-indicator{{display:inline-flex}}
.js-wrap.open .js-pop{{display:block}}.js-wrap.open .js-backdrop{{display:block}}
</style>
</head>
<body class="bg-stone-50 text-stone-900">
<script type="text/sx" data-components data-hash="{component_hash}">{component_defs}</script>
<script type="text/sx-pages">{pages_sx}</script>
<script type="text/sx" data-mount="body">{page_sx}</script>
<script src="{asset_url}/scripts/sx-browser.js?v={sx_js_hash}"></script>
<script src="{asset_url}/scripts/body.js?v={body_js_hash}"></script>
<div id="portal-root"></div>
</body>
</html>"""
# The page shell is defined as ~sx-page-shell in shared/sx/templates/shell.sx
# and rendered via render_to_html. No HTML string templates in Python.
def _build_pages_sx(service: str) -> str:
@@ -794,13 +751,16 @@ def _sx_literal(v: object) -> str:
def sx_page(ctx: dict, page_sx: str, *,
async def sx_page(ctx: dict, page_sx: str, *,
meta_html: str = "") -> str:
"""Return a minimal HTML shell that boots the page from sx source.
The browser loads component definitions and page sx, then sx.js
renders everything client-side. CSS rules are scanned from the sx
source and component defs, then injected as a <style> block.
The shell is rendered from the ~sx-page-shell SX component
(shared/sx/templates/shell.sx).
"""
from .jinja_bridge import components_for_page, css_classes_for_page
from .css_registry import lookup_rules, get_preamble, registry_loaded, store_css_hash
@@ -851,7 +811,8 @@ def sx_page(ctx: dict, page_sx: str, *,
if pages_sx:
_plog.debug("sx_page: pages_sx first 200 chars: %s", pages_sx[:200])
return _SX_PAGE_TEMPLATE.format(
return await render_to_html(
"sx-page-shell",
title=_html_escape(title),
asset_url=asset_url,
meta_html=meta_html,

View File

@@ -169,34 +169,42 @@
(< (inc (get state "i")) (len args)))
;; Keyword arg → attribute
(let ((attr-name (keyword-name arg))
(attr-val (trampoline
(eval-expr
(nth args (inc (get state "i")))
env))))
(attr-expr (nth args (inc (get state "i")))))
(cond
;; nil or false → skip
(or (nil? attr-val) (= attr-val false))
nil
;; Event handler: on-click, on-submit, on-input, etc.
;; Value must be callable (lambda/function)
(and (starts-with? attr-name "on-")
(callable? attr-val))
(dom-listen el (slice attr-name 3) attr-val)
;; Event handler: evaluate eagerly, bind listener
(starts-with? attr-name "on-")
(let ((attr-val (trampoline (eval-expr attr-expr env))))
(when (callable? attr-val)
(dom-listen el (slice attr-name 3) attr-val)))
;; Two-way input binding: :bind signal
(and (= attr-name "bind") (signal? attr-val))
(bind-input el attr-val)
(= attr-name "bind")
(let ((attr-val (trampoline (eval-expr attr-expr env))))
(when (signal? attr-val) (bind-input el attr-val)))
;; ref: set ref.current to this element
(= attr-name "ref")
(dict-set! attr-val "current" el)
;; Boolean attr
(contains? BOOLEAN_ATTRS attr-name)
(when attr-val (dom-set-attr el attr-name ""))
;; true → empty attr
(= attr-val true)
(dom-set-attr el attr-name "")
;; Normal attr
(let ((attr-val (trampoline (eval-expr attr-expr env))))
(dict-set! attr-val "current" el))
;; key: reconciliation hint, evaluate eagerly (not reactive)
(= attr-name "key")
(let ((attr-val (trampoline (eval-expr attr-expr env))))
(dom-set-attr el "key" (str attr-val)))
;; Inside island scope: reactive attribute binding.
;; The effect tracks signal deps automatically — if none
;; are deref'd, it fires once and never again (safe).
*island-scope*
(reactive-attr el attr-name
(fn () (trampoline (eval-expr attr-expr env))))
;; Static attribute (outside islands)
:else
(dom-set-attr el attr-name (str attr-val)))
(let ((attr-val (trampoline (eval-expr attr-expr env))))
(cond
(or (nil? attr-val) (= attr-val false)) nil
(contains? BOOLEAN_ATTRS attr-name)
(when attr-val (dom-set-attr el attr-name ""))
(= attr-val true)
(dom-set-attr el attr-name "")
:else
(dom-set-attr el attr-name (str attr-val)))))
(assoc state "skip" true "i" (inc (get state "i"))))
;; Positional arg → child
@@ -319,32 +327,131 @@
(define dispatch-render-form
(fn (name expr env ns)
(cond
;; if
;; if — reactive inside islands (re-renders when signal deps change)
(= name "if")
(let ((cond-val (trampoline (eval-expr (nth expr 1) env))))
(if cond-val
(render-to-dom (nth expr 2) env ns)
(if (> (len expr) 3)
(render-to-dom (nth expr 3) env ns)
(create-fragment))))
(if *island-scope*
(let ((marker (create-comment "r-if"))
(current-nodes (list))
(initial-result nil))
;; Effect runs synchronously on first call, tracking signal deps.
;; On first run, store result in initial-result (marker has no parent yet).
;; On subsequent runs, swap DOM nodes after marker.
(effect (fn ()
(let ((result (let ((cond-val (trampoline (eval-expr (nth expr 1) env))))
(if cond-val
(render-to-dom (nth expr 2) env ns)
(if (> (len expr) 3)
(render-to-dom (nth expr 3) env ns)
(create-fragment))))))
(if (dom-parent marker)
;; Marker is in DOM — swap nodes
(do
(for-each (fn (n) (dom-remove n)) current-nodes)
(set! current-nodes
(if (dom-is-fragment? result)
(dom-child-nodes result)
(list result)))
(dom-insert-after marker result))
;; Marker not yet in DOM (first run) — just save result
(set! initial-result result)))))
;; Return fragment: marker + initial render result
(let ((frag (create-fragment)))
(dom-append frag marker)
(when initial-result
(set! current-nodes
(if (dom-is-fragment? initial-result)
(dom-child-nodes initial-result)
(list initial-result)))
(dom-append frag initial-result))
frag))
;; Static if
(let ((cond-val (trampoline (eval-expr (nth expr 1) env))))
(if cond-val
(render-to-dom (nth expr 2) env ns)
(if (> (len expr) 3)
(render-to-dom (nth expr 3) env ns)
(create-fragment)))))
;; when
;; when — reactive inside islands
(= name "when")
(if (not (trampoline (eval-expr (nth expr 1) env)))
(create-fragment)
(let ((frag (create-fragment)))
(for-each
(fn (i)
(dom-append frag (render-to-dom (nth expr i) env ns)))
(range 2 (len expr)))
frag))
(if *island-scope*
(let ((marker (create-comment "r-when"))
(current-nodes (list))
(initial-result nil))
(effect (fn ()
(if (dom-parent marker)
;; In DOM — swap nodes
(do
(for-each (fn (n) (dom-remove n)) current-nodes)
(set! current-nodes (list))
(when (trampoline (eval-expr (nth expr 1) env))
(let ((frag (create-fragment)))
(for-each
(fn (i)
(dom-append frag (render-to-dom (nth expr i) env ns)))
(range 2 (len expr)))
(set! current-nodes (dom-child-nodes frag))
(dom-insert-after marker frag))))
;; First run — save result for fragment
(when (trampoline (eval-expr (nth expr 1) env))
(let ((frag (create-fragment)))
(for-each
(fn (i)
(dom-append frag (render-to-dom (nth expr i) env ns)))
(range 2 (len expr)))
(set! current-nodes (dom-child-nodes frag))
(set! initial-result frag))))))
(let ((frag (create-fragment)))
(dom-append frag marker)
(when initial-result (dom-append frag initial-result))
frag))
;; Static when
(if (not (trampoline (eval-expr (nth expr 1) env)))
(create-fragment)
(let ((frag (create-fragment)))
(for-each
(fn (i)
(dom-append frag (render-to-dom (nth expr i) env ns)))
(range 2 (len expr)))
frag)))
;; cond
;; cond — reactive inside islands
(= name "cond")
(let ((branch (eval-cond (rest expr) env)))
(if branch
(render-to-dom branch env ns)
(create-fragment)))
(if *island-scope*
(let ((marker (create-comment "r-cond"))
(current-nodes (list))
(initial-result nil))
(effect (fn ()
(let ((branch (eval-cond (rest expr) env)))
(if (dom-parent marker)
;; In DOM — swap nodes
(do
(for-each (fn (n) (dom-remove n)) current-nodes)
(set! current-nodes (list))
(when branch
(let ((result (render-to-dom branch env ns)))
(set! current-nodes
(if (dom-is-fragment? result)
(dom-child-nodes result)
(list result)))
(dom-insert-after marker result))))
;; First run — save result for fragment
(when branch
(let ((result (render-to-dom branch env ns)))
(set! current-nodes
(if (dom-is-fragment? result)
(dom-child-nodes result)
(list result)))
(set! initial-result result)))))))
(let ((frag (create-fragment)))
(dom-append frag marker)
(when initial-result (dom-append frag initial-result))
frag))
;; Static cond
(let ((branch (eval-cond (rest expr) env)))
(if branch
(render-to-dom branch env ns)
(create-fragment))))
;; case
(= name "case")
@@ -429,11 +536,11 @@
;; portal — render children into a remote target element
(= name "portal")
(render-dom-portal args env ns)
(render-dom-portal (rest expr) env ns)
;; error-boundary — catch errors, render fallback
(= name "error-boundary")
(render-dom-error-boundary args env ns)
(render-dom-error-boundary (rest expr) env ns)
;; for-each (render variant)
(= name "for-each")
@@ -620,9 +727,9 @@
(key-order (list)))
(dom-append container marker)
(effect (fn ()
(let ((parent (dom-parent marker))
(items (deref items-sig)))
(when parent
(let ((items (deref items-sig)))
(if (dom-parent marker)
;; Marker in DOM: reconcile
(let ((new-map (dict))
(new-keys (list))
(has-keys false))
@@ -674,7 +781,17 @@
;; Update state for next render
(set! key-map new-map)
(set! key-order new-keys))))))
(set! key-order new-keys))
;; First run (marker not in DOM yet): render initial items into container
(for-each-indexed
(fn (idx item)
(let ((rendered (render-list-item map-fn item env ns))
(key (extract-key rendered idx)))
(dict-set! key-map key rendered)
(append! key-order key)
(dom-append container rendered)))
items)))))
container)))
@@ -726,12 +843,10 @@
(define render-dom-portal
(fn (args env ns)
(let ((selector (trampoline (eval-expr (first args) env)))
(target (dom-query selector)))
(target (or (dom-query selector)
(dom-ensure-element selector))))
(if (not target)
;; Target not found — render nothing, log warning
(do
(log-warn (str "Portal target not found: " selector))
(create-comment (str "portal: " selector " (not found)")))
(create-comment (str "portal: " selector " (not found)"))
(let ((marker (create-comment (str "portal: " selector)))
(frag (create-fragment)))
;; Render children into the fragment
@@ -770,58 +885,49 @@
(let ((fallback-expr (first args))
(body-exprs (rest args))
(container (dom-create-element "div" nil))
(boundary-disposers (list)))
;; retry-version: bump this signal to force re-render after fallback
(retry-version (signal 0)))
(dom-set-attr container "data-sx-boundary" "true")
;; Render body with its own island scope for disposal
(let ((render-body
(fn ()
;; Dispose old boundary content
(for-each (fn (d) (d)) boundary-disposers)
(set! boundary-disposers (list))
;; The entire body is rendered inside ONE effect + try-catch.
;; Body renders WITHOUT *island-scope* so that if/when/cond use static
;; paths — their signal reads become direct deref calls tracked by THIS
;; effect. Errors from signal changes throw synchronously within try-catch.
;; The error boundary's own effect handles all reactivity for its subtree.
(effect (fn ()
;; Touch retry-version so the effect re-runs when retry is called
(deref retry-version)
;; Clear container
(dom-set-prop container "innerHTML" "")
;; Clear container
(dom-set-prop container "innerHTML" "")
;; Try to render body
(try-catch
(fn ()
;; Render body children, tracking disposers
(with-island-scope
(fn (disposable)
(append! boundary-disposers disposable)
(register-in-scope disposable))
(fn ()
(let ((frag (create-fragment)))
(for-each
(fn (child)
(dom-append frag (render-to-dom child env ns)))
body-exprs)
(dom-append container frag)))))
(fn (err)
;; Dispose any partially-created effects
(for-each (fn (d) (d)) boundary-disposers)
(set! boundary-disposers (list))
;; Save and clear island scope BEFORE try-catch so it can be
;; restored in both success and error paths.
(let ((saved-scope *island-scope*))
(set! *island-scope* nil)
(try-catch
(fn ()
;; Body renders statically — signal reads tracked by THIS effect,
;; throws propagate to our try-catch.
(let ((frag (create-fragment)))
(for-each
(fn (child)
(dom-append frag (render-to-dom child env ns)))
body-exprs)
(dom-append container frag))
(set! *island-scope* saved-scope))
(fn (err)
;; Restore scope first, then render fallback
(set! *island-scope* saved-scope)
(let ((fallback-fn (trampoline (eval-expr fallback-expr env)))
(retry-fn (fn () (swap! retry-version (fn (n) (+ n 1))))))
(let ((fallback-dom
(if (lambda? fallback-fn)
(render-lambda-dom fallback-fn (list err retry-fn) env ns)
(render-to-dom (apply fallback-fn (list err retry-fn)) env ns))))
(dom-append container fallback-dom))))))))
;; Render fallback with error + retry
(let ((fallback-fn (trampoline (eval-expr fallback-expr env)))
(retry-fn (fn () (render-body))))
(let ((fallback-dom
(if (lambda? fallback-fn)
(render-lambda-dom fallback-fn (list err retry-fn) env ns)
(render-to-dom (apply fallback-fn (list err retry-fn)) env ns))))
(dom-append container fallback-dom))))))))
;; Initial render
(render-body)
;; Register boundary disposers with parent island scope
(register-in-scope
(fn ()
(for-each (fn (d) (d)) boundary-disposers)
(set! boundary-disposers (list))))
container))))
container)))
;; --------------------------------------------------------------------------

View File

@@ -102,9 +102,11 @@ class JSEmitter:
# Map SX names to JS names
return self._mangle(name)
def _mangle(self, name: str) -> str:
"""Convert SX identifier to valid JS identifier."""
RENAMES = {
# Explicit SX→JS name mappings. Auto-mangle (kebab→camelCase, ?→_p, !→_b)
# is only a fallback. Every platform symbol used in spec .sx files MUST have
# an entry here — relying on auto-mangle is fragile and has caused runtime
# errors (e.g. has-key? → hasKey_p instead of dictHas).
RENAMES = {
"nil": "NIL",
"true": "true",
"false": "false",
@@ -328,6 +330,7 @@ class JSEmitter:
"dom-listen": "domListen",
"event-detail": "eventDetail",
"dom-query": "domQuery",
"dom-ensure-element": "domEnsureElement",
"dom-query-all": "domQueryAll",
"dom-tag-name": "domTagName",
"create-comment": "createComment",
@@ -338,6 +341,7 @@ class JSEmitter:
"dom-get-data": "domGetData",
"json-parse": "jsonParse",
"dict-has?": "dictHas",
"has-key?": "dictHas",
"dict-delete!": "dictDelete",
"process-bindings": "processBindings",
"eval-cond": "evalCond",
@@ -413,6 +417,7 @@ class JSEmitter:
"promise-resolve": "promiseResolve",
"promise-then": "promiseThen",
"promise-catch": "promiseCatch",
"promise-delayed": "promiseDelayed",
"abort-previous": "abortPrevious",
"track-controller": "trackController",
"new-abort-controller": "newAbortController",
@@ -590,9 +595,12 @@ class JSEmitter:
"match-route": "matchRoute",
"find-matching-route": "findMatchingRoute",
"for-each-indexed": "forEachIndexed",
}
if name in RENAMES:
return RENAMES[name]
}
def _mangle(self, name: str) -> str:
"""Convert SX identifier to valid JS identifier."""
if name in self.RENAMES:
return self.RENAMES[name]
# General mangling: replace - with camelCase, ? with _p, ! with _b
result = name
if result.endswith("?"):
@@ -2196,6 +2204,7 @@ PRIMITIVES_JS_MODULES: dict[str, str] = {
PRIMITIVES["odd?"] = function(n) { return n % 2 !== 0; };
PRIMITIVES["even?"] = function(n) { return n % 2 === 0; };
PRIMITIVES["zero?"] = function(n) { return n === 0; };
PRIMITIVES["boolean?"] = function(x) { return x === true || x === false; };
''',
"core.strings": '''
@@ -2219,6 +2228,7 @@ PRIMITIVES_JS_MODULES: dict[str, str] = {
PRIMITIVES["slice"] = function(c, a, b) { return b !== undefined ? c.slice(a, b) : c.slice(a); };
PRIMITIVES["substring"] = function(s, a, b) { return String(s).substring(a, b); };
PRIMITIVES["string-length"] = function(s) { return String(s).length; };
PRIMITIVES["string-contains?"] = function(s, sub) { return String(s).indexOf(String(sub)) !== -1; };
PRIMITIVES["concat"] = function() {
var out = [];
for (var i = 0; i < arguments.length; i++) if (!isNil(arguments[i])) out = out.concat(arguments[i]);
@@ -2254,6 +2264,12 @@ PRIMITIVES_JS_MODULES: dict[str, str] = {
PRIMITIVES["zip-pairs"] = function(c) {
var r = []; for (var i = 0; i < c.length - 1; i++) r.push([c[i], c[i + 1]]); return r;
};
PRIMITIVES["reverse"] = function(c) { return Array.isArray(c) ? c.slice().reverse() : String(c).split("").reverse().join(""); };
PRIMITIVES["flatten"] = function(c) {
var out = [];
function walk(a) { for (var i = 0; i < a.length; i++) Array.isArray(a[i]) ? walk(a[i]) : out.push(a[i]); }
walk(c || []); return out;
};
''',
"core.dict": '''
@@ -2276,6 +2292,7 @@ PRIMITIVES_JS_MODULES: dict[str, str] = {
return out;
};
PRIMITIVES["dict-set!"] = function(d, k, v) { d[k] = v; return v; };
PRIMITIVES["has-key?"] = function(d, k) { return d !== null && d !== undefined && k in d; };
PRIMITIVES["into"] = function(target, coll) {
if (Array.isArray(target)) return Array.isArray(coll) ? coll.slice() : Object.entries(coll);
var r = {}; for (var i = 0; i < coll.length; i++) { var p = coll[i]; if (Array.isArray(p) && p.length >= 2) r[p[0]] = p[1]; }
@@ -2906,6 +2923,20 @@ PLATFORM_DOM_JS = """
return _hasDom ? document.querySelector(sel) : null;
}
function domEnsureElement(sel) {
if (!_hasDom) return null;
var el = document.querySelector(sel);
if (el) return el;
// Parse #id selector → create div with that id, append to body
if (sel.charAt(0) === '#') {
el = document.createElement('div');
el.id = sel.slice(1);
document.body.appendChild(el);
return el;
}
return null;
}
function domQueryAll(root, sel) {
if (!root || !root.querySelectorAll) return [];
return Array.prototype.slice.call(root.querySelectorAll(sel));
@@ -3039,6 +3070,12 @@ PLATFORM_ORCHESTRATION_JS = """
function promiseCatch(p, fn) { return p && p.catch ? p.catch(fn) : p; }
function promiseDelayed(ms, value) {
return new Promise(function(resolve) {
setTimeout(function() { resolve(value); }, ms);
});
}
// --- Abort controllers ---
var _controllers = typeof WeakMap !== "undefined" ? new WeakMap() : null;
@@ -3075,8 +3112,9 @@ PLATFORM_ORCHESTRATION_JS = """
function clearTimeout_(id) { clearTimeout(id); }
function clearInterval_(id) { clearInterval(id); }
function requestAnimationFrame_(fn) {
if (typeof requestAnimationFrame !== "undefined") requestAnimationFrame(fn);
else setTimeout(fn, 16);
var cb = _wrapSxFn(fn);
if (typeof requestAnimationFrame !== "undefined") requestAnimationFrame(cb);
else setTimeout(cb, 16);
}
// --- Fetch ---
@@ -3475,14 +3513,19 @@ PLATFORM_ORCHESTRATION_JS = """
function stopPropagation_(e) { if (e && e.stopPropagation) e.stopPropagation(); }
function domFocus(el) { if (el && el.focus) el.focus(); }
function tryCatch(tryFn, catchFn) {
try { return tryFn(); } catch (e) { return catchFn(e); }
var t = _wrapSxFn(tryFn);
var c = catchFn && catchFn._lambda
? function(e) { return trampoline(callLambda(catchFn, [e], lambdaClosure(catchFn))); }
: catchFn;
try { return t(); } catch (e) { return c(e); }
}
function errorMessage(e) {
return e && e.message ? e.message : String(e);
}
function scheduleIdle(fn) {
if (typeof requestIdleCallback !== "undefined") requestIdleCallback(fn);
else setTimeout(fn, 0);
var cb = _wrapSxFn(fn);
if (typeof requestIdleCallback !== "undefined") requestIdleCallback(cb);
else setTimeout(cb, 0);
}
function elementValue(el) { return el && el.value !== undefined ? el.value : NIL; }
@@ -4028,11 +4071,24 @@ def fixups_js(has_html, has_sx, has_dom, has_signals=False):
PRIMITIVES["dom-listen"] = domListen;
PRIMITIVES["dom-dispatch"] = domDispatch;
PRIMITIVES["event-detail"] = eventDetail;
PRIMITIVES["resource"] = resource;
PRIMITIVES["promise-delayed"] = promiseDelayed;
PRIMITIVES["promise-then"] = promiseThen;
PRIMITIVES["def-store"] = defStore;
PRIMITIVES["use-store"] = useStore;
PRIMITIVES["emit-event"] = emitEvent;
PRIMITIVES["on-event"] = onEvent;
PRIMITIVES["bridge-event"] = bridgeEvent;''')
PRIMITIVES["bridge-event"] = bridgeEvent;
// DOM primitives for island code
PRIMITIVES["dom-focus"] = domFocus;
PRIMITIVES["dom-tag-name"] = domTagName;
PRIMITIVES["dom-get-prop"] = domGetProp;
PRIMITIVES["stop-propagation"] = stopPropagation_;
PRIMITIVES["error-message"] = errorMessage;
PRIMITIVES["schedule-idle"] = scheduleIdle;
PRIMITIVES["invoke"] = invoke;
PRIMITIVES["error"] = function(msg) { throw new Error(msg); };
PRIMITIVES["filter"] = filter;''')
return "\n".join(lines)

View File

@@ -476,7 +476,10 @@
(define sf-lambda
(fn (args env)
(let ((params-expr (first args))
(body (nth args 1))
(body-exprs (rest args))
(body (if (= (len body-exprs) 1)
(first body-exprs)
(cons (make-symbol "begin") body-exprs)))
(param-names (map (fn (p)
(if (= (type-of p) "symbol")
(symbol-name p)

View File

@@ -337,7 +337,32 @@
(deftest "higher-order returns lambda"
(let ((make-adder (fn (n) (fn (x) (+ n x)))))
(let ((add5 (make-adder 5)))
(assert-equal 8 (add5 3))))))
(assert-equal 8 (add5 3)))))
(deftest "multi-body lambda returns last value"
;; All body expressions must execute. Return value is the last.
;; Catches: sf-lambda using nth(args,1) instead of rest(args).
(let ((f (fn (x) (+ x 1) (+ x 2) (+ x 3))))
(assert-equal 13 (f 10))))
(deftest "multi-body lambda side effects via dict mutation"
;; Verify all body expressions run by mutating a shared dict.
(let ((state (dict "a" 0 "b" 0)))
(let ((f (fn ()
(dict-set! state "a" 1)
(dict-set! state "b" 2)
"done")))
(assert-equal "done" (f))
(assert-equal 1 (get state "a"))
(assert-equal 2 (get state "b")))))
(deftest "multi-body lambda two expressions"
;; Simplest case: two body expressions, return value is second.
(assert-equal 20
((fn (x) (+ x 1) (* x 2)) 10))
;; And with zero-arg lambda
(assert-equal 42
((fn () (+ 1 2) 42)))))
;; --------------------------------------------------------------------------

View File

@@ -1,10 +1,20 @@
"""Test bootstrapper transpilation: JSEmitter and PyEmitter."""
from __future__ import annotations
import os
import re
import pytest
from shared.sx.parser import parse
from shared.sx.ref.bootstrap_js import JSEmitter
from shared.sx.parser import parse, parse_all
from shared.sx.ref.bootstrap_js import (
JSEmitter,
ADAPTER_FILES,
SPEC_MODULES,
extract_defines,
compile_ref_to_js,
)
from shared.sx.ref.bootstrap_py import PyEmitter
from shared.sx.types import Symbol, Keyword
class TestJSEmitterNativeDict:
@@ -61,3 +71,159 @@ class TestPyEmitterNativeDict:
py = PyEmitter().emit(expr)
assert "'a': 1" in py
assert "'b': (x + 2)" in py
# ---------------------------------------------------------------------------
# Platform mapping and PRIMITIVES validation
#
# Catches two classes of bugs:
# 1. Spec defines missing from compiled JS: a function defined in an .sx
# spec file doesn't appear in the compiled output (e.g. because the
# spec module wasn't included).
# 2. Missing PRIMITIVES registration: a function is declared in
# primitives.sx but not registered in PRIMITIVES[...], so runtime-
# evaluated SX (island bodies) gets "Undefined symbol" errors.
# ---------------------------------------------------------------------------
_REF_DIR = os.path.join(os.path.dirname(__file__), "..", "ref")
class TestPlatformMapping:
"""Verify compiled JS output contains all spec-defined functions."""
def test_compiled_defines_present_in_js(self):
"""Every top-level define from spec files must appear in compiled JS output.
Catches: spec modules not included, _mangle producing wrong names for
defines, transpilation silently dropping definitions.
"""
js_output = compile_ref_to_js(
spec_modules=list(SPEC_MODULES.keys()),
)
# Collect all var/function definitions from the JS
defined_in_js = set(re.findall(r'\bvar\s+(\w+)\s*=', js_output))
defined_in_js.update(re.findall(r'\bfunction\s+(\w+)\s*\(', js_output))
all_defs: set[str] = set()
for filename, _label in (
[("eval.sx", "eval"), ("render.sx", "render")]
+ list(ADAPTER_FILES.values())
+ list(SPEC_MODULES.values())
):
filepath = os.path.join(_REF_DIR, filename)
if not os.path.exists(filepath):
continue
for name, _expr in extract_defines(open(filepath).read()):
all_defs.add(name)
emitter = JSEmitter()
missing = []
for sx_name in sorted(all_defs):
js_name = emitter._mangle(sx_name)
if js_name not in defined_in_js:
missing.append(f"{sx_name}{js_name}")
if missing:
pytest.fail(
f"{len(missing)} spec definitions not found in compiled JS "
f"(compile with all spec_modules):\n "
+ "\n ".join(missing)
)
def test_renames_values_are_unique(self):
"""RENAMES should not map different SX names to the same JS name.
Duplicate JS names would cause one definition to silently shadow another.
"""
renames = JSEmitter.RENAMES
seen: dict[str, str] = {}
dupes = []
for sx_name, js_name in sorted(renames.items()):
if js_name in seen:
# Allow intentional aliases (e.g. has-key? and dict-has?
# both → dictHas)
dupes.append(
f" {sx_name}{js_name} (same as {seen[js_name]})"
)
else:
seen[js_name] = sx_name
# Intentional aliases — these are expected duplicates
# (e.g. has-key? and dict-has? both map to dictHas)
# Don't fail for these, just document them
# The test serves as a warning for accidental duplicates
class TestPrimitivesRegistration:
"""Functions callable from runtime-evaluated SX must be in PRIMITIVES[...]."""
def test_declared_primitives_registered(self):
"""Every primitive declared in primitives.sx must have a PRIMITIVES[...] entry.
Primitives are called from runtime-evaluated SX (island bodies, user
components) via getPrimitive(). If a primitive is declared in
primitives.sx but not in PRIMITIVES[...], island code gets
"Undefined symbol" errors.
"""
from shared.sx.ref.boundary_parser import parse_primitives_sx
declared = parse_primitives_sx()
js_output = compile_ref_to_js()
registered = set(re.findall(r'PRIMITIVES\["([^"]+)"\]', js_output))
# Aliases — declared in primitives.sx under alternate names but
# served via canonical PRIMITIVES entries
aliases = {
"downcase": "lower",
"upcase": "upper",
"eq?": "=",
"eqv?": "=",
"equal?": "=",
}
for alias, canonical in aliases.items():
if alias in declared and canonical in registered:
declared = declared - {alias}
# Extension-only primitives (require continuations extension)
extension_only = {"continuation?"}
declared = declared - extension_only
missing = declared - registered
if missing:
pytest.fail(
f"{len(missing)} primitives declared in primitives.sx but "
f"not registered in PRIMITIVES[...]:\n "
+ "\n ".join(sorted(missing))
)
def test_signal_runtime_primitives_registered(self):
"""Signal/reactive functions used by island bodies must be in PRIMITIVES.
These are the reactive primitives that island SX code calls via
getPrimitive(). If any is missing, islands with reactive state fail
at runtime.
"""
required = {
"signal", "signal?", "deref", "reset!", "swap!",
"computed", "effect", "batch", "resource",
"def-store", "use-store", "emit-event", "on-event", "bridge-event",
"promise-delayed", "promise-then",
"dom-focus", "dom-tag-name", "dom-get-prop",
"stop-propagation", "error-message", "schedule-idle",
"set-interval", "clear-interval",
"reactive-text", "create-text-node",
"dom-set-text-content", "dom-listen", "dom-dispatch", "event-detail",
}
js_output = compile_ref_to_js()
registered = set(re.findall(r'PRIMITIVES\["([^"]+)"\]', js_output))
missing = required - registered
if missing:
pytest.fail(
f"{len(missing)} signal/reactive primitives not registered "
f"in PRIMITIVES[...]:\n "
+ "\n ".join(sorted(missing))
)