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))
|
||||
)
|
||||
|
||||
@@ -1143,3 +1143,90 @@
|
||||
"No build step. No bundler. No transpiler. No package manager. No CSS preprocessor. No dev server. No linter. No formatter. No type checker. No framework CLI. No code editor.")
|
||||
(p :class "text-stone-600"
|
||||
"Zero tools."))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; React is Hypermedia
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~essay-react-is-hypermedia ()
|
||||
(~doc-page :title "React is Hypermedia"
|
||||
(p :class "text-stone-500 text-sm italic mb-8"
|
||||
"A React Island is a hypermedia control. Its behavior is specified in SX.")
|
||||
(~doc-section :title "I. The argument" :id "argument"
|
||||
(p :class "text-stone-600"
|
||||
"React is not hypermedia. Everyone knows this. React is a JavaScript UI library. It renders components to a virtual DOM. It diffs. It patches. It manages state. It does none of the things that define " (a :href "https://en.wikipedia.org/wiki/Hypermedia" :class "text-violet-600 hover:underline" "hypermedia") " — server-driven content, links as the primary interaction mechanism, representations that carry their own controls.")
|
||||
(p :class "text-stone-600"
|
||||
"And yet. Consider what a React Island actually is:")
|
||||
(ul :class "list-disc pl-6 space-y-1 text-stone-600"
|
||||
(li "It is embedded in a server-rendered page.")
|
||||
(li "Its initial content is delivered as HTML (or as serialised SX, which the client renders to DOM).")
|
||||
(li "It occupies a region of the page — a bounded area with a defined boundary.")
|
||||
(li "It responds to user interaction by mutating its own DOM.")
|
||||
(li "It does not fetch data. It does not route. It does not manage application state outside its boundary."))
|
||||
(p :class "text-stone-600"
|
||||
"This is a " (a :href "https://en.wikipedia.org/wiki/Hypermedia#Controls" :class "text-violet-600 hover:underline" "hypermedia control") ". It is a region of a hypermedia document that responds to user input. Like a " (code "<form>") ". Like an " (code "<a>") ". Like an " (code "<input>") ". The difference is that a form's behavior is specified by the browser and the HTTP protocol. An island's behavior is specified in SX."))
|
||||
(~doc-section :title "II. What makes something hypermedia" :id "hypermedia"
|
||||
(p :class "text-stone-600"
|
||||
"Roy " (a :href "https://en.wikipedia.org/wiki/Roy_Fielding" :class "text-violet-600 hover:underline" "Fielding") "'s " (a :href "https://en.wikipedia.org/wiki/Representational_state_transfer" :class "text-violet-600 hover:underline" "REST") " thesis defines hypermedia by a constraint: " (em "hypermedia as the engine of application state") " (HATEOAS). The server sends representations that include controls — links, forms — and the client's state transitions are driven by those controls. The client does not need out-of-band knowledge of what actions are available. The representation " (em "is") " the interface.")
|
||||
(p :class "text-stone-600"
|
||||
"A traditional SPA violates this. The client has its own router, its own state machine, its own API client that knows the server's URL structure. The HTML is a shell; the actual interface is constructed from JavaScript and API calls. The representation is not the interface — the representation is a loading spinner while the real interface builds itself.")
|
||||
(p :class "text-stone-600"
|
||||
"An SX page does not violate this. The server sends a complete representation — an s-expression tree — that includes all controls. Some controls are plain HTML: " (code "(a :href \"/about\" :sx-get \"/about\")") ". Some controls are reactive islands: " (code "(defisland counter (let ((count (signal 0))) ...))") ". Both are embedded in the representation. Both are delivered by the server. The client does not decide what controls exist — the server does, by including them in the document.")
|
||||
(p :class "text-stone-600"
|
||||
"The island is not separate from the hypermedia. The island " (em "is") " part of the hypermedia. It is a control that the server chose to include, whose behavior the server specified, in the same format as the rest of the page."))
|
||||
(~doc-section :title "III. The SX specification layer" :id "spec-layer"
|
||||
(p :class "text-stone-600"
|
||||
"A " (code "<form>") "'s behavior is specified in HTML + HTTP: " (code "method=\"POST\"") ", " (code "action=\"/submit\"") ". The browser reads the specification and executes it — serialise the inputs, make the request, handle the response. The form does not contain JavaScript. Its behavior is declared.")
|
||||
(p :class "text-stone-600"
|
||||
"An SX island's behavior is specified in SX:")
|
||||
(~doc-code :lang "lisp" :code
|
||||
"(defisland todo-adder\n (let ((text (signal \"\")))\n (form :on-submit (fn (e)\n (prevent-default e)\n (emit-event \"todo:add\" (deref text))\n (reset! text \"\"))\n (input :type \"text\"\n :bind text\n :placeholder \"What needs doing?\")\n (button :type \"submit\" \"Add\"))))")
|
||||
(p :class "text-stone-600"
|
||||
"This is a " (em "declaration") ", not a program. It declares: there is a signal holding text. There is a form. When submitted, it emits an event and resets the signal. There is an input bound to the signal. There is a button.")
|
||||
(p :class "text-stone-600"
|
||||
"The s-expression " (em "is") " the specification. It is not compiled to JavaScript and then executed as an opaque blob. It is parsed, evaluated, and rendered by a transparent evaluator whose own semantics are specified in the same format (" (code "eval.sx") "). The island's behavior is as inspectable as a form's " (code "action") " attribute — you can read it, quote it, transform it, analyse it. You can even send it over the wire and have a different client render it.")
|
||||
(p :class "text-stone-600"
|
||||
"A form says " (em "what to do") " in HTML attributes. An island says " (em "what to do") " in s-expressions. Both are declarative. Both are part of the hypermedia document. The difference is expressiveness: forms can collect inputs and POST them. Islands can maintain local state, compute derived values, animate transitions, handle errors, and render dynamic lists — all declared in the same markup language as the page that contains them."))
|
||||
(~doc-section :title "IV. The four levels" :id "four-levels"
|
||||
(p :class "text-stone-600"
|
||||
"SX reactive islands exist at four levels of complexity, from pure hypermedia to full client reactivity. Each level is a superset of the one before:")
|
||||
(ul :class "list-disc pl-6 space-y-2 text-stone-600"
|
||||
(li (span :class "font-semibold" "L0 — Static server rendering.") " No client interactivity. The server evaluates the full component tree and sends HTML. Pure hypermedia. " (code "(div :class \"card\" (h2 title))") ".")
|
||||
(li (span :class "font-semibold" "L1 — Hypermedia attributes.") " Server-rendered content with htmx-style attributes. " (code "(button :sx-get \"/items\" :sx-target \"#list\")") ". Still server-driven. The client swaps HTML fragments. Classic hypermedia with AJAX.")
|
||||
(li (span :class "font-semibold" "L2 — Reactive islands.") " Self-contained client-side state within a server-rendered page. " (code "(defisland counter ...)") ". The island is a hypermedia control: the server delivers it, the client executes it. Signals, computed values, effects — all inside the island boundary.")
|
||||
(li (span :class "font-semibold" "L3 — Island communication.") " Islands talk to each other and to the htmx-like \"lake\" via DOM events. " (code "(emit-event \"cart:updated\" count)") " and " (code "(on-event \"cart:updated\" handler)") ". Still no global state. Still no client-side routing. The page is still a server document with embedded controls."))
|
||||
(p :class "text-stone-600"
|
||||
"At every level, the architecture is hypermedia. The server produces the document. The document contains controls. The controls are specified in SX. The jump from L1 to L2 is not a jump from hypermedia to SPA — it is a jump from " (em "simple controls") " (links and forms) to " (em "richer controls") " (reactive islands). The paradigm does not change. The expressiveness does."))
|
||||
(~doc-section :title "V. Why not just React?" :id "why-not-react"
|
||||
(p :class "text-stone-600"
|
||||
"If an island behaves like a React component — local state, event handlers, conditional rendering — why not use React?")
|
||||
(p :class "text-stone-600"
|
||||
"Because React requires a " (em "build") ". JSX must be compiled. Modules must be bundled. The result is an opaque JavaScript blob that the server cannot inspect, the wire format cannot represent, and the client must execute before anything is visible. The component's specification — its source code — is lost by the time it reaches the browser.")
|
||||
(p :class "text-stone-600"
|
||||
"An SX island arrives at the browser as source. The same s-expression that defined the island on the server is the s-expression that the client parses and evaluates. There is no compilation, no bundling, no build step. The specification " (em "is") " the artifact.")
|
||||
(p :class "text-stone-600"
|
||||
"This matters because hypermedia's core property is " (em "self-description") ". A hypermedia representation carries its own controls and its own semantics. An HTML form is self-describing: the browser reads the " (code "action") " and " (code "method") " and knows what to do. A compiled React component is not self-describing: it is a function that was once source code, compiled away into instructions that only the React runtime can interpret.")
|
||||
(p :class "text-stone-600"
|
||||
"SX islands are self-describing. The source is the artifact. The representation carries its own semantics. This is what makes them hypermedia controls — not because they avoid JavaScript (they don't), but because the behavior specification travels with the document, in the same format as the document."))
|
||||
(~doc-section :title "VI. The bridge pattern" :id "bridge"
|
||||
(p :class "text-stone-600"
|
||||
"In practice, the hypermedia and the islands coexist through a pattern: the htmx \"lake\" surrounds the reactive \"islands.\" The lake handles navigation, form submission, content loading — classic hypermedia. The islands handle local interaction — counters, toggles, filters, input validation, animations.")
|
||||
(p :class "text-stone-600"
|
||||
"Communication between lake and islands uses DOM events. An island can " (code "emit-event") " to tell the page something happened. A server-rendered button can " (code "bridge-event") " to poke an island when clicked. The DOM — the shared medium — is the only coupling.")
|
||||
(~doc-code :lang "lisp" :code
|
||||
";; Server-rendered lake button dispatches to island\n(button :sx-get \"/api/refresh\"\n :sx-target \"#results\"\n :on-click (bridge-event \"search:clear\")\n \"Reset\")\n\n;; Island listens for the event\n(defisland search-filter\n (let ((query (signal \"\")))\n (on-event \"search:clear\" (fn () (reset! query \"\")))\n (input :bind query :placeholder \"Filter...\")))")
|
||||
(p :class "text-stone-600"
|
||||
"The lake button does its hypermedia thing — fetches HTML, swaps it in. Simultaneously, it dispatches a DOM event. The island hears the event and clears its state. Neither knows about the other's implementation. They communicate through the hypermedia document's event system — the DOM.")
|
||||
(p :class "text-stone-600"
|
||||
"This is not a hybrid architecture bolting two incompatible models together. It is a single model — hypermedia — with controls of varying complexity. Some controls are links. Some are forms. Some are reactive islands. All are specified in the document. All are delivered by the server."))
|
||||
(~doc-section :title "VII. The specification is the specification" :id "specification"
|
||||
(p :class "text-stone-600"
|
||||
"The deepest claim is not architectural but philosophical. A React Island — the kind with signals and effects and computed values — is a " (em "behavior specification") ". It specifies: when this signal changes, recompute this derived value, re-render this DOM subtree. When this event fires, update this state. When this input changes, validate against this pattern.")
|
||||
(p :class "text-stone-600"
|
||||
"In React, this specification is written in JavaScript and destroyed by compilation. The specification exists only in the developer's source file. The user receives a bundle.")
|
||||
(p :class "text-stone-600"
|
||||
"In SX, this specification is written in s-expressions, transmitted as s-expressions, parsed as s-expressions, and evaluated as s-expressions. The specification exists at every stage of the pipeline. It is never destroyed. It is never transformed into something else. It arrives at the browser intact, readable, inspectable.")
|
||||
(p :class "text-stone-600"
|
||||
"And the evaluator that interprets this specification? It is itself specified in s-expressions (" (code "eval.sx") "). And the renderer? Specified in s-expressions (" (code "render.sx") "). And the parser? Specified in s-expressions (" (code "parser.sx") "). The specification language specifies itself. The island's behavior is specified in a language whose behavior is specified in itself.")
|
||||
(p :class "text-stone-600"
|
||||
"A React Island is a hypermedia control. Its behavior is specified in SX. And SX is specified in SX. There is no layer beneath. The specification goes all the way down."))))
|
||||
|
||||
@@ -91,7 +91,9 @@
|
||||
(dict :label "sx sucks" :href "/essays/sx-sucks"
|
||||
:summary "An honest accounting of everything wrong with SX and why you probably shouldn't use it.")
|
||||
(dict :label "Tools for Fools" :href "/essays/zero-tooling"
|
||||
:summary "SX was built without a code editor. No IDE, no build tools, no linters, no bundlers. What zero-tooling web development looks like.")))
|
||||
:summary "SX was built without a code editor. No IDE, no build tools, no linters, no bundlers. What zero-tooling web development looks like.")
|
||||
(dict :label "React is Hypermedia" :href "/essays/react-is-hypermedia"
|
||||
:summary "A React Island is a hypermedia control. Its behavior is specified in SX.")))
|
||||
|
||||
(define philosophy-nav-items (list
|
||||
(dict :label "The SX Manifesto" :href "/philosophy/sx-manifesto"
|
||||
|
||||
@@ -145,19 +145,23 @@
|
||||
(td :class "px-3 py-2 text-stone-700" "Phase 2 remaining")
|
||||
(td :class "px-3 py-2 text-stone-500 font-medium" "P2")
|
||||
(td :class "px-3 py-2 font-mono text-xs text-stone-500"
|
||||
(a :href "/reactive-islands/phase2" :sx-get "/reactive-islands/phase2" :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true" :class "text-violet-700 underline" "Error boundaries, suspense, transitions")))
|
||||
(a :href "/reactive-islands/phase2" :sx-get "/reactive-islands/phase2" :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true" :class "text-violet-700 underline" "Error boundaries + resource + patterns")))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 text-stone-700" "Error boundaries")
|
||||
(td :class "px-3 py-2 text-green-700 font-medium" "Done")
|
||||
(td :class "px-3 py-2 font-mono text-xs text-stone-500" "adapter-dom.sx: error-boundary render-dom form"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 text-stone-700" "Suspense")
|
||||
(td :class "px-3 py-2 text-stone-500 font-medium" "N/A")
|
||||
(td :class "px-3 py-2 font-mono text-xs text-stone-500" "covered by existing primitives"))
|
||||
(td :class "px-3 py-2 text-stone-700" "Resource (async signal)")
|
||||
(td :class "px-3 py-2 text-green-700 font-medium" "Done")
|
||||
(td :class "px-3 py-2 font-mono text-xs text-stone-500" "signals.sx: resource, promise-then"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 text-stone-700" "Suspense pattern")
|
||||
(td :class "px-3 py-2 text-green-700 font-medium" "Done")
|
||||
(td :class "px-3 py-2 font-mono text-xs text-stone-500" "resource + cond/deref (no special form)"))
|
||||
(tr
|
||||
(td :class "px-3 py-2 text-stone-700" "Transitions")
|
||||
(td :class "px-3 py-2 text-stone-500 font-medium" "N/A")
|
||||
(td :class "px-3 py-2 font-mono text-xs text-stone-500" "covered by existing primitives"))))))))
|
||||
(td :class "px-3 py-2 text-stone-700" "Transition pattern")
|
||||
(td :class "px-3 py-2 text-green-700 font-medium" "Done")
|
||||
(td :class "px-3 py-2 font-mono text-xs text-stone-500" "schedule-idle + batch (no special form)"))))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Live demo islands
|
||||
@@ -324,6 +328,160 @@
|
||||
:on-click (fn (e) (reset! open? false))
|
||||
"Close"))))))))
|
||||
|
||||
;; 8. Error boundary — catch errors, render fallback with retry
|
||||
(defisland ~demo-error-boundary ()
|
||||
(let ((throw? (signal false)))
|
||||
(div :class "rounded border border-violet-200 bg-violet-50 p-4 my-4"
|
||||
(div :class "flex items-center gap-3 mb-3"
|
||||
(button :class "px-3 py-1 rounded bg-red-600 text-white text-sm font-medium hover:bg-red-700"
|
||||
:on-click (fn (e) (reset! throw? true))
|
||||
"Trigger Error")
|
||||
(button :class "px-3 py-1 rounded bg-stone-300 text-stone-700 text-sm hover:bg-stone-400"
|
||||
:on-click (fn (e) (reset! throw? false))
|
||||
"Reset"))
|
||||
(error-boundary
|
||||
;; Fallback: receives (err retry-fn)
|
||||
(fn (err retry-fn)
|
||||
(div :class "p-3 bg-red-50 border border-red-200 rounded"
|
||||
(p :class "text-red-700 font-medium text-sm" "Caught: " (error-message err))
|
||||
(button :class "mt-2 px-3 py-1 rounded bg-red-600 text-white text-sm hover:bg-red-700"
|
||||
:on-click (fn (e) (do (reset! throw? false) (invoke retry-fn)))
|
||||
"Retry")))
|
||||
;; Children: the happy path
|
||||
(do
|
||||
(when (deref throw?)
|
||||
(error "Intentional explosion!"))
|
||||
(p :class "text-sm text-green-700"
|
||||
"Everything is fine. Click \"Trigger Error\" to throw."))))))
|
||||
|
||||
;; 9. Refs — imperative DOM access via :ref attribute
|
||||
(defisland ~demo-refs ()
|
||||
(let ((my-ref (dict "current" nil))
|
||||
(msg (signal "")))
|
||||
(div :class "rounded border border-violet-200 bg-violet-50 p-4 my-4 space-y-3"
|
||||
(input :ref my-ref :type "text" :placeholder "I can be focused programmatically"
|
||||
:class "px-3 py-1.5 rounded border border-stone-300 text-sm focus:outline-none focus:border-violet-400 w-64")
|
||||
(div :class "flex items-center gap-3"
|
||||
(button :class "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700"
|
||||
:on-click (fn (e)
|
||||
(dom-focus (get my-ref "current")))
|
||||
"Focus Input")
|
||||
(button :class "px-3 py-1 rounded bg-stone-300 text-stone-700 text-sm hover:bg-stone-400"
|
||||
:on-click (fn (e)
|
||||
(let ((el (get my-ref "current")))
|
||||
(reset! msg (str "Tag: " (dom-tag-name el)
|
||||
", value: \"" (dom-get-prop el "value") "\""))))
|
||||
"Read Input"))
|
||||
(when (not (= (deref msg) ""))
|
||||
(p :class "text-sm text-stone-600 font-mono" (deref msg))))))
|
||||
|
||||
;; 10. Dynamic class/style — computed signals drive class and style reactively
|
||||
(defisland ~demo-dynamic-class ()
|
||||
(let ((danger (signal false))
|
||||
(size (signal 16)))
|
||||
(div :class "rounded border border-violet-200 bg-violet-50 p-4 my-4 space-y-3"
|
||||
(div :class "flex items-center gap-3"
|
||||
(button :class "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700"
|
||||
:on-click (fn (e) (swap! danger not))
|
||||
(if (deref danger) "Safe mode" "Danger mode"))
|
||||
(button :class "px-3 py-1 rounded bg-stone-300 text-stone-700 text-sm hover:bg-stone-400"
|
||||
:on-click (fn (e) (swap! size (fn (s) (+ s 2))))
|
||||
"Bigger")
|
||||
(button :class "px-3 py-1 rounded bg-stone-300 text-stone-700 text-sm hover:bg-stone-400"
|
||||
:on-click (fn (e) (swap! size (fn (s) (max 10 (- s 2)))))
|
||||
"Smaller"))
|
||||
(div :class (str "p-3 rounded font-medium transition-colors "
|
||||
(if (deref danger) "bg-red-100 text-red-800" "bg-green-100 text-green-800"))
|
||||
:style (str "font-size:" (deref size) "px")
|
||||
"This element's class and style are reactive."))))
|
||||
|
||||
;; 11. Resource + suspense pattern — async data with loading/error states
|
||||
(defisland ~demo-resource ()
|
||||
(let ((data (resource (fn ()
|
||||
;; Simulate async fetch with a delayed promise
|
||||
(promise-delayed 1500 (dict "name" "Ada Lovelace"
|
||||
"role" "First Programmer"
|
||||
"year" 1843))))))
|
||||
(div :class "rounded border border-violet-200 bg-violet-50 p-4 my-4"
|
||||
(cond
|
||||
(get (deref data) "loading")
|
||||
(div :class "flex items-center gap-2 text-stone-500"
|
||||
(span :class "inline-block w-4 h-4 border-2 border-stone-300 border-t-violet-600 rounded-full animate-spin")
|
||||
(span :class "text-sm" "Loading..."))
|
||||
(get (deref data) "error")
|
||||
(div :class "p-3 bg-red-50 border border-red-200 rounded"
|
||||
(p :class "text-red-700 text-sm" "Error: " (get (deref data) "error")))
|
||||
:else
|
||||
(let ((d (get (deref data) "data")))
|
||||
(div :class "space-y-1"
|
||||
(p :class "font-bold text-stone-800" (get d "name"))
|
||||
(p :class "text-sm text-stone-600" (get d "role") " (" (get d "year") ")")))))))
|
||||
|
||||
;; 12. Transition pattern — deferred updates for expensive operations
|
||||
(defisland ~demo-transition ()
|
||||
(let ((query (signal ""))
|
||||
(all-items (list "Signals" "Effects" "Computed" "Batch" "Stores"
|
||||
"Islands" "Portals" "Error Boundaries" "Resources"
|
||||
"Input Binding" "Keyed Lists" "Event Bridge"
|
||||
"Reactive Text" "Reactive Attrs" "Reactive Fragments"
|
||||
"Disposal" "Hydration" "CSSX" "Macros" "Refs"))
|
||||
(filtered (signal (list)))
|
||||
(pending (signal false)))
|
||||
;; Set initial filtered list
|
||||
(reset! filtered all-items)
|
||||
;; Filter effect — defers via schedule-idle so typing stays snappy
|
||||
(effect (fn ()
|
||||
(let ((q (lower (deref query))))
|
||||
(if (= q "")
|
||||
(do (reset! pending false)
|
||||
(reset! filtered all-items))
|
||||
(do (reset! pending true)
|
||||
(schedule-idle (fn ()
|
||||
(batch (fn ()
|
||||
(reset! filtered
|
||||
(filter (fn (item) (contains? (lower item) q)) all-items))
|
||||
(reset! pending false))))))))))
|
||||
(div :class "rounded border border-violet-200 bg-violet-50 p-4 my-4 space-y-3"
|
||||
(div :class "flex items-center gap-3"
|
||||
(input :type "text" :bind query :placeholder "Filter features..."
|
||||
:class "px-3 py-1.5 rounded border border-stone-300 text-sm focus:outline-none focus:border-violet-400 w-48")
|
||||
(when (deref pending)
|
||||
(span :class "text-xs text-stone-400" "Filtering...")))
|
||||
(ul :class "space-y-1"
|
||||
(map (fn (item)
|
||||
(li :key item :class "text-sm text-stone-700 bg-white rounded px-3 py-1.5"
|
||||
item))
|
||||
(deref filtered))))))
|
||||
|
||||
;; 13. Shared stores — cross-island state via def-store / use-store
|
||||
(defisland ~demo-store-writer ()
|
||||
(let ((store (def-store "demo-theme" (fn ()
|
||||
(dict "color" (signal "violet")
|
||||
"dark" (signal false))))))
|
||||
(div :class "rounded border border-stone-200 bg-stone-50 p-4 my-2"
|
||||
(p :class "text-xs font-semibold text-stone-500 mb-2" "Island A — Store Writer")
|
||||
(div :class "flex items-center gap-3"
|
||||
(select :bind (get store "color")
|
||||
:class "px-2 py-1 rounded border border-stone-300 text-sm"
|
||||
(option :value "violet" "Violet")
|
||||
(option :value "blue" "Blue")
|
||||
(option :value "green" "Green")
|
||||
(option :value "red" "Red"))
|
||||
(label :class "flex items-center gap-1 text-sm text-stone-600"
|
||||
(input :type "checkbox" :bind (get store "dark")
|
||||
:class "rounded border-stone-300")
|
||||
"Dark mode")))))
|
||||
|
||||
(defisland ~demo-store-reader ()
|
||||
(let ((store (use-store "demo-theme")))
|
||||
(div :class "rounded border border-stone-200 bg-stone-50 p-4 my-2"
|
||||
(p :class "text-xs font-semibold text-stone-500 mb-2" "Island B — Store Reader")
|
||||
(div :class (str "p-3 rounded font-medium text-sm "
|
||||
(if (deref (get store "dark"))
|
||||
(str "bg-" (deref (get store "color")) "-900 text-" (deref (get store "color")) "-100")
|
||||
(str "bg-" (deref (get store "color")) "-100 text-" (deref (get store "color")) "-800")))
|
||||
"Styled by signals from Island A"))))
|
||||
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Demo page — shows what's been implemented
|
||||
@@ -378,19 +536,134 @@
|
||||
(~doc-code :code (highlight "(defisland ~demo-portal ()\n (let ((open? (signal false)))\n (div\n (button :on-click (fn (e) (swap! open? not))\n (if (deref open?) \"Close Modal\" \"Open Modal\"))\n (portal \"#portal-root\"\n (when (deref open?)\n (div :class \"fixed inset-0 bg-black/50 ...\"\n :on-click (fn (e) (reset! open? false))\n (div :class \"bg-white rounded-lg p-6 ...\"\n :on-click (fn (e) (stop-propagation e))\n (h2 \"Portal Modal\")\n (p \"Rendered outside the island's DOM.\")\n (button :on-click (fn (e) (reset! open? false))\n \"Close\"))))))))" "lisp"))
|
||||
(p "The portal content lives in " (code "#portal-root") " (typically at the page body level), not inside the island. On island disposal, portal content is automatically removed from its target — the " (code "register-in-scope") " mechanism handles cleanup."))
|
||||
|
||||
(~doc-section :title "8. How defisland Works" :id "how-defisland"
|
||||
(~doc-section :title "8. Error Boundaries" :id "demo-error-boundary"
|
||||
(p "When an island's rendering or effect throws, " (code "error-boundary") " catches the error and renders a fallback. The fallback receives the error and a retry function. Partial effects created before the error are disposed automatically.")
|
||||
(~demo-error-boundary)
|
||||
(~doc-code :code (highlight "(defisland ~demo-error-boundary ()\n (let ((throw? (signal false)))\n (error-boundary\n ;; Fallback: receives (err retry-fn)\n (fn (err retry-fn)\n (div :class \"p-3 bg-red-50 border border-red-200 rounded\"\n (p :class \"text-red-700\" (error-message err))\n (button :on-click (fn (e)\n (reset! throw? false) (invoke retry-fn))\n \"Retry\")))\n ;; Children: the happy path\n (do\n (when (deref throw?) (error \"Intentional explosion!\"))\n (p \"Everything is fine.\")))))" "lisp"))
|
||||
(p "React equivalent: " (code "componentDidCatch") " / " (code "ErrorBoundary") ". SX's version is simpler — one form, not a class. The " (code "error-boundary") " form is a render-dom special form in " (code "adapter-dom.sx") "."))
|
||||
|
||||
(~doc-section :title "9. Refs — Imperative DOM Access" :id "demo-refs"
|
||||
(p "The " (code ":ref") " attribute captures a DOM element handle into a dict. Use it for imperative operations: focusing, measuring, reading values.")
|
||||
(~demo-refs)
|
||||
(~doc-code :code (highlight "(defisland ~demo-refs ()\n (let ((my-ref (dict \"current\" nil))\n (msg (signal \"\")))\n (input :ref my-ref :type \"text\"\n :placeholder \"I can be focused programmatically\")\n (button :on-click (fn (e)\n (dom-focus (get my-ref \"current\")))\n \"Focus Input\")\n (button :on-click (fn (e)\n (let ((el (get my-ref \"current\")))\n (reset! msg (str \"value: \" (dom-get-prop el \"value\")))))\n \"Read Input\")\n (when (not (= (deref msg) \"\"))\n (p (deref msg)))))" "lisp"))
|
||||
(p "React equivalent: " (code "useRef") ". In SX, a ref is just " (code "(dict \"current\" nil)") " — no special API. The " (code ":ref") " attribute sets " (code "(dict-set! ref \"current\" el)") " when the element is created. Read it with " (code "(get ref \"current\")") "."))
|
||||
|
||||
(~doc-section :title "10. Dynamic Class and Style" :id "demo-dynamic-class"
|
||||
(p "React uses " (code "className") " and " (code "style") " props with state. SX does the same — " (code "(deref signal)") " inside a " (code ":class") " or " (code ":style") " attribute creates a reactive binding. The attribute updates when the signal changes.")
|
||||
(~demo-dynamic-class)
|
||||
(~doc-code :code (highlight "(defisland ~demo-dynamic-class ()\n (let ((danger (signal false))\n (size (signal 16)))\n (div\n (button :on-click (fn (e) (swap! danger not))\n (if (deref danger) \"Safe mode\" \"Danger mode\"))\n (button :on-click (fn (e) (swap! size (fn (s) (+ s 2))))\n \"Bigger\")\n ;; Reactive class — recomputed when danger changes\n (div :class (str \"p-3 rounded font-medium \"\n (if (deref danger)\n \"bg-red-100 text-red-800\"\n \"bg-green-100 text-green-800\"))\n ;; Reactive style — recomputed when size changes\n :style (str \"font-size:\" (deref size) \"px\")\n \"This element's class and style are reactive.\"))))" "lisp"))
|
||||
(p "React equivalent: " (code "className={danger ? 'red' : 'green'}") " and " (code "style={{fontSize: size}}") ". In SX the " (code "str") " + " (code "if") " + " (code "deref") " pattern handles it — no " (code "classnames") " library needed. For complex conditional classes, use a " (code "computed") " or a CSSX " (code "defcomp") " that returns a class string."))
|
||||
|
||||
(~doc-section :title "11. Resource + Suspense Pattern" :id "demo-resource"
|
||||
(p (code "resource") " wraps an async operation into a signal with " (code "loading") "/" (code "data") "/" (code "error") " states. Combined with " (code "cond") " + " (code "deref") ", this is the suspense pattern — no special form needed.")
|
||||
(~demo-resource)
|
||||
(~doc-code :code (highlight "(defisland ~demo-resource ()\n (let ((data (resource (fn ()\n ;; Any promise-returning function\n (promise-delayed 1500\n (dict \"name\" \"Ada Lovelace\"\n \"role\" \"First Programmer\"))))))\n ;; This IS the suspense pattern:\n (let ((state (deref data)))\n (cond\n (get state \"loading\")\n (div \"Loading...\")\n (get state \"error\")\n (div \"Error: \" (get state \"error\"))\n :else\n (div (get (get state \"data\") \"name\"))))))" "lisp"))
|
||||
(p "React equivalent: " (code "Suspense") " + " (code "use()") " or " (code "useSWR") ". SX doesn't need a special " (code "suspense") " form because " (code "resource") " returns a signal and " (code "cond") " + " (code "deref") " creates reactive conditional rendering. When the promise resolves, the signal updates and the " (code "cond") " branch switches automatically."))
|
||||
|
||||
(~doc-section :title "12. Transition Pattern" :id "demo-transition"
|
||||
(p "React's " (code "startTransition") " defers non-urgent updates so typing stays responsive. In SX: " (code "schedule-idle") " + " (code "batch") ". The filter runs during idle time, not blocking the input event.")
|
||||
(~demo-transition)
|
||||
(~doc-code :code (highlight "(defisland ~demo-transition ()\n (let ((query (signal \"\"))\n (all-items (list \"Signals\" \"Effects\" ...))\n (filtered (signal (list)))\n (pending (signal false)))\n (reset! filtered all-items)\n ;; Filter effect — deferred via schedule-idle\n (effect (fn ()\n (let ((q (lower (deref query))))\n (if (= q \"\")\n (do (reset! pending false)\n (reset! filtered all-items))\n (do (reset! pending true)\n (schedule-idle (fn ()\n (batch (fn ()\n (reset! filtered\n (filter (fn (item)\n (contains? (lower item) q))\n all-items))\n (reset! pending false))))))))))\n (div\n (input :bind query :placeholder \"Filter...\")\n (when (deref pending) (span \"Filtering...\"))\n (ul (map (fn (item) (li :key item item))\n (deref filtered))))))" "lisp"))
|
||||
(p "React equivalent: " (code "startTransition(() => setFiltered(...))") ". SX uses " (code "schedule-idle") " (" (code "requestIdleCallback") " under the hood) to defer the expensive " (code "filter") " operation, and " (code "batch") " to group the result into one update. Fine-grained signals already avoid the jank that makes transitions critical in React — this pattern is for truly expensive computations."))
|
||||
|
||||
(~doc-section :title "13. Shared Stores" :id "demo-stores"
|
||||
(p "React uses " (code "Context") " or state management libraries for cross-component state. SX uses " (code "def-store") " / " (code "use-store") " — named signal containers that persist across island creation/destruction.")
|
||||
(~demo-store-writer)
|
||||
(~demo-store-reader)
|
||||
(~doc-code :code (highlight ";; Island A — creates/writes the store\n(defisland ~store-writer ()\n (let ((store (def-store \"theme\" (fn ()\n (dict \"color\" (signal \"violet\")\n \"dark\" (signal false))))))\n (select :bind (get store \"color\")\n (option :value \"violet\" \"Violet\")\n (option :value \"blue\" \"Blue\"))\n (input :type \"checkbox\" :bind (get store \"dark\"))))\n\n;; Island B — reads the same store, different island\n(defisland ~store-reader ()\n (let ((store (use-store \"theme\")))\n (div :class (str \"bg-\" (deref (get store \"color\")) \"-100\")\n \"Styled by signals from Island A\")))" "lisp"))
|
||||
(p "React equivalent: " (code "createContext") " + " (code "useContext") " or Redux/Zustand. Stores are simpler — just named dicts of signals at page scope. " (code "def-store") " creates once, " (code "use-store") " retrieves. Stores survive island disposal but clear on full page navigation."))
|
||||
|
||||
(~doc-section :title "14. How defisland Works" :id "how-defisland"
|
||||
(p (code "defisland") " creates a reactive component. Same calling convention as " (code "defcomp") " — keyword args, rest children — but with a reactive boundary. Inside an island, " (code "deref") " subscribes DOM nodes to signals.")
|
||||
(~doc-code :code (highlight ";; Definition — same syntax as defcomp\n(defisland ~counter (&key initial)\n (let ((count (signal (or initial 0))))\n (div\n (span (deref count)) ;; reactive text node\n (button :on-click (fn (e) (swap! count inc)) ;; event handler\n \"+\"))))\n\n;; Usage — same as any component\n(~counter :initial 42)\n\n;; Server-side rendering:\n;; <div data-sx-island=\"counter\" data-sx-state='{\"initial\":42}'>\n;; <span>42</span><button>+</button>\n;; </div>\n;;\n;; Client hydrates: signals + effects + event handlers attach" "lisp"))
|
||||
(p "Each " (code "deref") " call registers the enclosing DOM node as a subscriber. Signal changes update " (em "only") " the subscribed nodes — no virtual DOM, no diffing, no component re-renders."))
|
||||
|
||||
(~doc-section :title "9. Test suite" :id "demo-tests"
|
||||
(~doc-section :title "15. Test suite" :id "demo-tests"
|
||||
(p "17 tests verify the signal runtime against the spec. All pass in the Python test runner (which uses the hand-written evaluator with native platform primitives).")
|
||||
(~doc-code :code (highlight ";; Signal basics (6 tests)\n(assert-true (signal? (signal 42)))\n(assert-equal 42 (deref (signal 42)))\n(assert-equal 5 (deref 5)) ;; non-signal passthrough\n\n;; reset! changes value\n(let ((s (signal 0)))\n (reset! s 10)\n (assert-equal 10 (deref s)))\n\n;; reset! does NOT notify when value unchanged (identical? check)\n\n;; Computed (3 tests)\n(let ((a (signal 3)) (b (signal 4))\n (sum (computed (fn () (+ (deref a) (deref b))))))\n (assert-equal 7 (deref sum))\n (reset! a 10)\n (assert-equal 14 (deref sum)))\n\n;; Effects (4 tests) — immediate run, re-run on change, dispose, cleanup\n;; Batch (1 test) — defers notifications, deduplicates subscribers\n;; defisland (3 tests) — creates island, callable, accepts children" "lisp"))
|
||||
(p :class "mt-2 text-sm text-stone-500" "Run: " (code "python3 shared/sx/tests/run.py signals")))
|
||||
|
||||
(~doc-section :title "What's next" :id "next"
|
||||
(p "Phase 1 and Phase 2 are complete. The reactive islands system now includes: signals, effects, computed values, islands, disposal, stores, event bridges, reactive DOM rendering, input binding, keyed reconciliation, portals, error boundaries, and resource.")
|
||||
(p "See the " (a :href "/reactive-islands/phase2" :sx-get "/reactive-islands/phase2" :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true" :class "text-violet-700 underline" "Phase 2 plan") " for the full feature list and design details."))))
|
||||
(~doc-section :title "React Feature Coverage" :id "coverage"
|
||||
(p "Every React feature has an SX equivalent — most are simpler because signals are fine-grained.")
|
||||
(div :class "overflow-x-auto rounded border border-stone-200"
|
||||
(table :class "w-full text-left text-sm"
|
||||
(thead (tr :class "border-b border-stone-200 bg-stone-100"
|
||||
(th :class "px-3 py-2 font-medium text-stone-600" "React")
|
||||
(th :class "px-3 py-2 font-medium text-stone-600" "SX")
|
||||
(th :class "px-3 py-2 font-medium text-stone-600" "Demo")))
|
||||
(tbody
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 text-stone-700" "useState")
|
||||
(td :class "px-3 py-2 font-mono text-xs text-violet-700" "(signal value)")
|
||||
(td :class "px-3 py-2 text-xs text-stone-500" "#1"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 text-stone-700" "useMemo")
|
||||
(td :class "px-3 py-2 font-mono text-xs text-violet-700" "(computed (fn () ...))")
|
||||
(td :class "px-3 py-2 text-xs text-stone-500" "#1, #2"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 text-stone-700" "useEffect")
|
||||
(td :class "px-3 py-2 font-mono text-xs text-violet-700" "(effect (fn () ...))")
|
||||
(td :class "px-3 py-2 text-xs text-stone-500" "#3"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 text-stone-700" "useRef")
|
||||
(td :class "px-3 py-2 font-mono text-xs text-violet-700" "(dict \"current\" nil) + :ref")
|
||||
(td :class "px-3 py-2 text-xs text-stone-500" "#9"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 text-stone-700" "useCallback")
|
||||
(td :class "px-3 py-2 font-mono text-xs text-violet-700" "(fn (...) ...) — no dep arrays")
|
||||
(td :class "px-3 py-2 text-xs text-stone-500" "N/A"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 text-stone-700" "className / style")
|
||||
(td :class "px-3 py-2 font-mono text-xs text-violet-700" ":class (str ...) :style (str ...)")
|
||||
(td :class "px-3 py-2 text-xs text-stone-500" "#10"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 text-stone-700" "Controlled inputs")
|
||||
(td :class "px-3 py-2 font-mono text-xs text-violet-700" ":bind signal")
|
||||
(td :class "px-3 py-2 text-xs text-stone-500" "#6"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 text-stone-700" "key prop")
|
||||
(td :class "px-3 py-2 font-mono text-xs text-violet-700" ":key value")
|
||||
(td :class "px-3 py-2 text-xs text-stone-500" "#5"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 text-stone-700" "createPortal")
|
||||
(td :class "px-3 py-2 font-mono text-xs text-violet-700" "(portal \"#target\" ...)")
|
||||
(td :class "px-3 py-2 text-xs text-stone-500" "#7"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 text-stone-700" "ErrorBoundary")
|
||||
(td :class "px-3 py-2 font-mono text-xs text-violet-700" "(error-boundary fallback ...)")
|
||||
(td :class "px-3 py-2 text-xs text-stone-500" "#8"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 text-stone-700" "Suspense + use()")
|
||||
(td :class "px-3 py-2 font-mono text-xs text-violet-700" "(resource fn) + cond/deref")
|
||||
(td :class "px-3 py-2 text-xs text-stone-500" "#11"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 text-stone-700" "startTransition")
|
||||
(td :class "px-3 py-2 font-mono text-xs text-violet-700" "schedule-idle + batch")
|
||||
(td :class "px-3 py-2 text-xs text-stone-500" "#12"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 text-stone-700" "Context / Redux")
|
||||
(td :class "px-3 py-2 font-mono text-xs text-violet-700" "def-store / use-store")
|
||||
(td :class "px-3 py-2 text-xs text-stone-500" "#13"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 text-stone-700" "Virtual DOM / diffing")
|
||||
(td :class "px-3 py-2 text-xs text-stone-500" "N/A — fine-grained signals update exact DOM nodes")
|
||||
(td :class "px-3 py-2 text-xs text-stone-500" ""))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 text-stone-700" "JSX / build step")
|
||||
(td :class "px-3 py-2 text-xs text-stone-500" "N/A — s-expressions are the syntax")
|
||||
(td :class "px-3 py-2 text-xs text-stone-500" ""))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 text-stone-700" "Server Components")
|
||||
(td :class "px-3 py-2 text-xs text-stone-500" "N/A — aser mode already expands server-side")
|
||||
(td :class "px-3 py-2 text-xs text-stone-500" ""))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 text-stone-700" "Concurrent rendering")
|
||||
(td :class "px-3 py-2 text-xs text-stone-500" "N/A — fine-grained updates are inherently incremental")
|
||||
(td :class "px-3 py-2 text-xs text-stone-500" ""))
|
||||
(tr
|
||||
(td :class "px-3 py-2 text-stone-700" "Hooks rules")
|
||||
(td :class "px-3 py-2 text-xs text-stone-500" "N/A — signals are values, no ordering rules")
|
||||
(td :class "px-3 py-2 text-xs text-stone-500" ""))))))))
|
||||
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
@@ -282,6 +282,7 @@
|
||||
"sx-and-ai" (~essay-sx-and-ai)
|
||||
"no-alternative" (~essay-no-alternative)
|
||||
"zero-tooling" (~essay-zero-tooling)
|
||||
"react-is-hypermedia" (~essay-react-is-hypermedia)
|
||||
:else (~essays-index-content)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user