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
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:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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)))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
@@ -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))
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user