Phase 5: async IO rendering — components call IO primitives client-side

Wire async rendering into client-side routing: pages whose component
trees reference IO primitives (highlight, current-user, etc.) now
render client-side via Promise-aware asyncRenderToDom. IO calls proxy
through /sx/io/<name> endpoint, which falls back to page helpers.

- Add has-io flag to page registry entries (helpers.py)
- Remove IO purity filter — include IO-dependent components in bundles
- Extend try-client-route with 4 paths: pure, data, IO, data+IO
- Convert tryAsyncEvalContent to callback style, add platform mapping
- IO proxy falls back to page helpers (highlight works via proxy)
- Demo page: /isomorphism/async-io with inline highlight calls

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 08:12:42 +00:00
parent 04ff03f5d4
commit 79fa1411dc
10 changed files with 1502 additions and 264 deletions

View File

@@ -14,7 +14,7 @@
// =========================================================================
var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } });
var SX_VERSION = "2026-03-07T02:10:28Z";
var SX_VERSION = "2026-03-07T02:32:34Z";
function isNil(x) { return x === NIL || x === null || x === undefined; }
function isSxTruthy(x) { return x !== false && !isNil(x); }
@@ -565,92 +565,6 @@
return makeThunk(componentBody(comp), local);
};
// =========================================================================
// Platform: deps module — component dependency analysis
// =========================================================================
function componentDeps(c) {
return c.deps ? c.deps.slice() : [];
}
function componentSetDeps(c, deps) {
c.deps = deps;
}
function componentCssClasses(c) {
return c.cssClasses ? c.cssClasses.slice() : [];
}
function envComponents(env) {
var names = [];
for (var k in env) {
var v = env[k];
if (v && (v._component || v._macro)) names.push(k);
}
return names;
}
function regexFindAll(pattern, source) {
var re = new RegExp(pattern, "g");
var results = [];
var m;
while ((m = re.exec(source)) !== null) {
if (m[1] !== undefined) results.push(m[1]);
else results.push(m[0]);
}
return results;
}
function scanCssClasses(source) {
var classes = {};
var result = [];
var m;
var re1 = /:class\s+"([^"]*)"/g;
while ((m = re1.exec(source)) !== null) {
var parts = m[1].split(/\s+/);
for (var i = 0; i < parts.length; i++) {
if (parts[i] && !classes[parts[i]]) {
classes[parts[i]] = true;
result.push(parts[i]);
}
}
}
var re2 = /:class\s+\(str\s+((?:"[^"]*"\s*)+)\)/g;
while ((m = re2.exec(source)) !== null) {
var re3 = /"([^"]*)"/g;
var m2;
while ((m2 = re3.exec(m[1])) !== null) {
var parts2 = m2[1].split(/\s+/);
for (var j = 0; j < parts2.length; j++) {
if (parts2[j] && !classes[parts2[j]]) {
classes[parts2[j]] = true;
result.push(parts2[j]);
}
}
}
}
var re4 = /;;\s*@css\s+(.+)/g;
while ((m = re4.exec(source)) !== null) {
var parts3 = m[1].split(/\s+/);
for (var k = 0; k < parts3.length; k++) {
if (parts3[k] && !classes[parts3[k]]) {
classes[parts3[k]] = true;
result.push(parts3[k]);
}
}
}
return result;
}
function componentIoRefs(c) {
return c.ioRefs ? c.ioRefs.slice() : [];
}
function componentSetIoRefs(c, refs) {
c.ioRefs = refs;
}
// =========================================================================
// Platform interface — Parser
// =========================================================================
@@ -2116,24 +2030,31 @@ return domAppendToHead(link); }, domQueryAll(container, "link[rel=\"stylesheet\"
var pageName = get(match, "name");
return (isSxTruthy(sxOr(isNil(contentSrc), isEmpty(contentSrc))) ? (logWarn((String("sx:route no content for ") + String(pathname))), false) : (function() {
var target = resolveRouteTarget(targetSel);
return (isSxTruthy(isNil(target)) ? (logWarn((String("sx:route target not found: ") + String(targetSel))), false) : (isSxTruthy(!isSxTruthy(depsSatisfied_p(match))) ? (logInfo((String("sx:route deps miss for ") + String(pageName))), false) : (isSxTruthy(get(match, "has-data")) ? (function() {
return (isSxTruthy(isNil(target)) ? (logWarn((String("sx:route target not found: ") + String(targetSel))), false) : (isSxTruthy(!isSxTruthy(depsSatisfied_p(match))) ? (logInfo((String("sx:route deps miss for ") + String(pageName))), false) : (function() {
var hasIo = get(match, "has-io");
return (isSxTruthy(get(match, "has-data")) ? (function() {
var cacheKey = pageDataCacheKey(pageName, params);
var cached = pageDataCacheGet(cacheKey);
return (isSxTruthy(cached) ? (function() {
var env = merge(closure, params, cached);
return (isSxTruthy(hasIo) ? (logInfo((String("sx:route client+cache+async ") + String(pathname))), tryAsyncEvalContent(contentSrc, env, function(rendered) { return (isSxTruthy(isNil(rendered)) ? logWarn((String("sx:route async eval failed for ") + String(pathname))) : swapRenderedContent(target, rendered, pathname)); }), true) : (function() {
var rendered = tryEvalContent(contentSrc, env);
return (isSxTruthy(isNil(rendered)) ? (logWarn((String("sx:route cached eval failed for ") + String(pathname))), false) : (logInfo((String("sx:route client+cache ") + String(pathname))), swapRenderedContent(target, rendered, pathname), true));
})());
})() : (logInfo((String("sx:route client+data ") + String(pathname))), resolvePageData(pageName, params, function(data) { pageDataCacheSet(cacheKey, data);
return (function() {
var env = merge(closure, params, data);
return (isSxTruthy(hasIo) ? tryAsyncEvalContent(contentSrc, env, function(rendered) { return (isSxTruthy(isNil(rendered)) ? logWarn((String("sx:route data+async eval failed for ") + String(pathname))) : swapRenderedContent(target, rendered, pathname)); }) : (function() {
var rendered = tryEvalContent(contentSrc, env);
return (isSxTruthy(isNil(rendered)) ? logWarn((String("sx:route data eval failed for ") + String(pathname))) : swapRenderedContent(target, rendered, pathname));
})());
})(); }), true));
})() : (function() {
})() : (isSxTruthy(hasIo) ? (logInfo((String("sx:route client+async ") + String(pathname))), tryAsyncEvalContent(contentSrc, merge(closure, params), function(rendered) { return (isSxTruthy(isNil(rendered)) ? logWarn((String("sx:route async eval failed for ") + String(pathname))) : swapRenderedContent(target, rendered, pathname)); }), true) : (function() {
var env = merge(closure, params);
var rendered = tryEvalContent(contentSrc, env);
return (isSxTruthy(isNil(rendered)) ? (logInfo((String("sx:route server (eval failed) ") + String(pathname))), false) : (swapRenderedContent(target, rendered, pathname), true));
})())));
})()));
})()));
})());
})());
})(); };
@@ -2555,120 +2476,7 @@ callExpr.push(dictGet(kwargs, k)); } }
})(); };
// boot-init
var bootInit = function() { return (logInfo((String("sx-browser ") + String(SX_VERSION))), initCssTracking(), initStyleDict(), processPageScripts(), processSxScripts(NIL), sxHydrateElements(NIL), processElements(NIL)); };
// === Transpiled from deps (component dependency analysis) ===
// scan-refs
var scanRefs = function(node) { return (function() {
var refs = [];
scanRefsWalk(node, refs);
return refs;
})(); };
// scan-refs-walk
var scanRefsWalk = function(node, refs) { return (isSxTruthy((typeOf(node) == "symbol")) ? (function() {
var name = symbolName(node);
return (isSxTruthy(startsWith(name, "~")) ? (isSxTruthy(!isSxTruthy(contains(refs, name))) ? append_b(refs, name) : NIL) : NIL);
})() : (isSxTruthy((typeOf(node) == "list")) ? forEach(function(item) { return scanRefsWalk(item, refs); }, node) : (isSxTruthy((typeOf(node) == "dict")) ? forEach(function(key) { return scanRefsWalk(dictGet(node, key), refs); }, keys(node)) : NIL))); };
// transitive-deps-walk
var transitiveDepsWalk = function(n, seen, env) { return (isSxTruthy(!isSxTruthy(contains(seen, n))) ? (append_b(seen, n), (function() {
var val = envGet(env, n);
return (isSxTruthy((typeOf(val) == "component")) ? forEach(function(ref) { return transitiveDepsWalk(ref, seen, env); }, scanRefs(componentBody(val))) : (isSxTruthy((typeOf(val) == "macro")) ? forEach(function(ref) { return transitiveDepsWalk(ref, seen, env); }, scanRefs(macroBody(val))) : NIL));
})()) : NIL); };
// transitive-deps
var transitiveDeps = function(name, env) { return (function() {
var seen = [];
var key = (isSxTruthy(startsWith(name, "~")) ? name : (String("~") + String(name)));
transitiveDepsWalk(key, seen, env);
return filter(function(x) { return !isSxTruthy((x == key)); }, seen);
})(); };
// compute-all-deps
var computeAllDeps = function(env) { return forEach(function(name) { return (function() {
var val = envGet(env, name);
return (isSxTruthy((typeOf(val) == "component")) ? componentSetDeps(val, transitiveDeps(name, env)) : NIL);
})(); }, envComponents(env)); };
// scan-components-from-source
var scanComponentsFromSource = function(source) { return (function() {
var matches = regexFindAll("\\(~([a-zA-Z_][a-zA-Z0-9_\\-]*)", source);
return map(function(m) { return (String("~") + String(m)); }, matches);
})(); };
// components-needed
var componentsNeeded = function(pageSource, env) { return (function() {
var direct = scanComponentsFromSource(pageSource);
var allNeeded = [];
{ var _c = direct; for (var _i = 0; _i < _c.length; _i++) { var name = _c[_i]; if (isSxTruthy(!isSxTruthy(contains(allNeeded, name)))) {
allNeeded.push(name);
}
(function() {
var val = envGet(env, name);
return (function() {
var deps = (isSxTruthy((isSxTruthy((typeOf(val) == "component")) && !isSxTruthy(isEmpty(componentDeps(val))))) ? componentDeps(val) : transitiveDeps(name, env));
return forEach(function(dep) { return (isSxTruthy(!isSxTruthy(contains(allNeeded, dep))) ? append_b(allNeeded, dep) : NIL); }, deps);
})();
})(); } }
return allNeeded;
})(); };
// page-component-bundle
var pageComponentBundle = function(pageSource, env) { return componentsNeeded(pageSource, env); };
// page-css-classes
var pageCssClasses = function(pageSource, env) { return (function() {
var needed = componentsNeeded(pageSource, env);
var classes = [];
{ var _c = needed; for (var _i = 0; _i < _c.length; _i++) { var name = _c[_i]; (function() {
var val = envGet(env, name);
return (isSxTruthy((typeOf(val) == "component")) ? forEach(function(cls) { return (isSxTruthy(!isSxTruthy(contains(classes, cls))) ? append_b(classes, cls) : NIL); }, componentCssClasses(val)) : NIL);
})(); } }
{ var _c = scanCssClasses(pageSource); for (var _i = 0; _i < _c.length; _i++) { var cls = _c[_i]; if (isSxTruthy(!isSxTruthy(contains(classes, cls)))) {
classes.push(cls);
} } }
return classes;
})(); };
// scan-io-refs-walk
var scanIoRefsWalk = function(node, ioNames, refs) { return (isSxTruthy((typeOf(node) == "symbol")) ? (function() {
var name = symbolName(node);
return (isSxTruthy(contains(ioNames, name)) ? (isSxTruthy(!isSxTruthy(contains(refs, name))) ? append_b(refs, name) : NIL) : NIL);
})() : (isSxTruthy((typeOf(node) == "list")) ? forEach(function(item) { return scanIoRefsWalk(item, ioNames, refs); }, node) : (isSxTruthy((typeOf(node) == "dict")) ? forEach(function(key) { return scanIoRefsWalk(dictGet(node, key), ioNames, refs); }, keys(node)) : NIL))); };
// scan-io-refs
var scanIoRefs = function(node, ioNames) { return (function() {
var refs = [];
scanIoRefsWalk(node, ioNames, refs);
return refs;
})(); };
// transitive-io-refs-walk
var transitiveIoRefsWalk = function(n, seen, allRefs, env, ioNames) { return (isSxTruthy(!isSxTruthy(contains(seen, n))) ? (append_b(seen, n), (function() {
var val = envGet(env, n);
return (isSxTruthy((typeOf(val) == "component")) ? (forEach(function(ref) { return (isSxTruthy(!isSxTruthy(contains(allRefs, ref))) ? append_b(allRefs, ref) : NIL); }, scanIoRefs(componentBody(val), ioNames)), forEach(function(dep) { return transitiveIoRefsWalk(dep, seen, allRefs, env, ioNames); }, scanRefs(componentBody(val)))) : (isSxTruthy((typeOf(val) == "macro")) ? (forEach(function(ref) { return (isSxTruthy(!isSxTruthy(contains(allRefs, ref))) ? append_b(allRefs, ref) : NIL); }, scanIoRefs(macroBody(val), ioNames)), forEach(function(dep) { return transitiveIoRefsWalk(dep, seen, allRefs, env, ioNames); }, scanRefs(macroBody(val)))) : NIL));
})()) : NIL); };
// transitive-io-refs
var transitiveIoRefs = function(name, env, ioNames) { return (function() {
var allRefs = [];
var seen = [];
var key = (isSxTruthy(startsWith(name, "~")) ? name : (String("~") + String(name)));
transitiveIoRefsWalk(key, seen, allRefs, env, ioNames);
return allRefs;
})(); };
// compute-all-io-refs
var computeAllIoRefs = function(env, ioNames) { return forEach(function(name) { return (function() {
var val = envGet(env, name);
return (isSxTruthy((typeOf(val) == "component")) ? componentSetIoRefs(val, transitiveIoRefs(name, env, ioNames)) : NIL);
})(); }, envComponents(env)); };
// component-pure?
var componentPure_p = function(name, env, ioNames) { return isEmpty(transitiveIoRefs(name, env, ioNames)); };
var bootInit = function() { return (logInfo((String("sx-browser ") + String(SX_VERSION))), initCssTracking(), initStyleDict(), initIoPrimitives(), processPageScripts(), processSxScripts(NIL), sxHydrateElements(NIL), processElements(NIL)); };
// === Transpiled from router (client-side route matching) ===
@@ -3510,6 +3318,32 @@ callExpr.push(dictGet(kwargs, k)); } }
}
}
// Async eval with callback — used for pages with IO deps.
// Calls callback(rendered) when done, callback(null) on failure.
function tryAsyncEvalContent(source, env, callback) {
var merged = merge(componentEnv);
if (env && !isNil(env)) {
var ks = Object.keys(env);
for (var i = 0; i < ks.length; i++) merged[ks[i]] = env[ks[i]];
}
try {
var result = asyncSxRenderWithEnv(source, merged);
if (isPromise(result)) {
result.then(function(rendered) {
callback(rendered);
}).catch(function(e) {
logInfo("sx:async eval miss: " + (e && e.message ? e.message : e));
callback(null);
});
} else {
callback(result);
}
} catch (e) {
logInfo("sx:async eval miss: " + (e && e.message ? e.message : e));
callback(null);
}
}
function resolvePageData(pageName, params, callback) {
// Platform implementation: fetch page data via HTTP from /sx/data/ endpoint.
// The spec only knows about resolve-page-data(name, params, callback) —
@@ -3939,6 +3773,605 @@ callExpr.push(dictGet(kwargs, k)); } }
if (typeof aser === "function") PRIMITIVES["aser"] = aser;
if (typeof renderToDom === "function") PRIMITIVES["render-to-dom"] = renderToDom;
// =========================================================================
// Async IO: Promise-aware rendering for client-side IO primitives
// =========================================================================
//
// IO primitives (query, current-user, etc.) return Promises on the client.
// asyncRenderToDom walks the component tree; when it encounters an IO
// primitive, it awaits the Promise and continues rendering.
//
// The sync evaluator/renderer is untouched. This is a separate async path
// used only when a page's component tree contains IO references.
var IO_PRIMITIVES = {};
function registerIoPrimitive(name, fn) {
IO_PRIMITIVES[name] = fn;
}
function isPromise(x) {
return x != null && typeof x === "object" && typeof x.then === "function";
}
// Async trampoline: resolves thunks, awaits Promises
function asyncTrampoline(val) {
if (isPromise(val)) return val.then(asyncTrampoline);
if (isThunk(val)) return asyncTrampoline(evalExpr(thunkExpr(val), thunkEnv(val)));
return val;
}
// Async eval: like trampoline(evalExpr(...)) but handles IO primitives
function asyncEval(expr, env) {
// Intercept IO primitive calls at the AST level
if (Array.isArray(expr) && expr.length > 0) {
var head = expr[0];
if (head && head._sym) {
var name = head.name;
if (IO_PRIMITIVES[name]) {
// Evaluate args, then call the IO primitive
return asyncEvalIoCall(name, expr.slice(1), env);
}
}
}
// Non-IO: use sync eval, but result might be a thunk
var result = evalExpr(expr, env);
return asyncTrampoline(result);
}
function asyncEvalIoCall(name, rawArgs, env) {
// Parse keyword args and positional args, evaluating each (may be async)
var kwargs = {};
var args = [];
var promises = [];
var i = 0;
while (i < rawArgs.length) {
var arg = rawArgs[i];
if (arg && arg._kw && (i + 1) < rawArgs.length) {
var kName = arg.name;
var kVal = asyncEval(rawArgs[i + 1], env);
if (isPromise(kVal)) {
(function(k) { promises.push(kVal.then(function(v) { kwargs[k] = v; })); })(kName);
} else {
kwargs[kName] = kVal;
}
i += 2;
} else {
var aVal = asyncEval(arg, env);
if (isPromise(aVal)) {
(function(idx) { promises.push(aVal.then(function(v) { args[idx] = v; })); })(args.length);
args.push(null); // placeholder
} else {
args.push(aVal);
}
i++;
}
}
var ioFn = IO_PRIMITIVES[name];
if (promises.length > 0) {
return Promise.all(promises).then(function() { return ioFn(args, kwargs); });
}
return ioFn(args, kwargs);
}
// Async render-to-dom: returns Promise<Node> or Node
function asyncRenderToDom(expr, env, ns) {
// Literals
if (expr === NIL || expr === null || expr === undefined) return null;
if (expr === true || expr === false) return null;
if (typeof expr === "string") return document.createTextNode(expr);
if (typeof expr === "number") return document.createTextNode(String(expr));
// Symbol -> async eval then render
if (expr && expr._sym) {
var val = asyncEval(expr, env);
if (isPromise(val)) return val.then(function(v) { return asyncRenderToDom(v, env, ns); });
return asyncRenderToDom(val, env, ns);
}
// Keyword
if (expr && expr._kw) return document.createTextNode(expr.name);
// DocumentFragment / DOM nodes pass through
if (expr instanceof DocumentFragment || (expr && expr.nodeType)) return expr;
// Dict -> skip
if (expr && typeof expr === "object" && !Array.isArray(expr)) return null;
// List
if (!Array.isArray(expr) || expr.length === 0) return null;
var head = expr[0];
if (!head) return null;
// Symbol head
if (head._sym) {
var hname = head.name;
// IO primitive
if (IO_PRIMITIVES[hname]) {
var ioResult = asyncEval(expr, env);
if (isPromise(ioResult)) return ioResult.then(function(v) { return asyncRenderToDom(v, env, ns); });
return asyncRenderToDom(ioResult, env, ns);
}
// Fragment
if (hname === "<>") return asyncRenderChildren(expr.slice(1), env, ns);
// raw!
if (hname === "raw!") {
return asyncEvalRaw(expr.slice(1), env);
}
// Special forms that need async handling
if (hname === "if") return asyncRenderIf(expr, env, ns);
if (hname === "when") return asyncRenderWhen(expr, env, ns);
if (hname === "cond") return asyncRenderCond(expr, env, ns);
if (hname === "case") return asyncRenderCase(expr, env, ns);
if (hname === "let" || hname === "let*") return asyncRenderLet(expr, env, ns);
if (hname === "begin" || hname === "do") return asyncRenderChildren(expr.slice(1), env, ns);
if (hname === "map") return asyncRenderMap(expr, env, ns);
if (hname === "map-indexed") return asyncRenderMapIndexed(expr, env, ns);
if (hname === "for-each") return asyncRenderMap(expr, env, ns);
// define/defcomp/defmacro — eval for side effects
if (hname === "define" || hname === "defcomp" || hname === "defmacro" ||
hname === "defstyle" || hname === "defkeyframes" || hname === "defhandler") {
trampoline(evalExpr(expr, env));
return null;
}
// quote
if (hname === "quote") return null;
// lambda/fn
if (hname === "lambda" || hname === "fn") {
trampoline(evalExpr(expr, env));
return null;
}
// and/or — eval and render result
if (hname === "and" || hname === "or" || hname === "->") {
var aoResult = asyncEval(expr, env);
if (isPromise(aoResult)) return aoResult.then(function(v) { return asyncRenderToDom(v, env, ns); });
return asyncRenderToDom(aoResult, env, ns);
}
// set!
if (hname === "set!") {
asyncEval(expr, env);
return null;
}
// Component
if (hname.charAt(0) === "~") {
var comp = env[hname];
if (comp && comp._component) return asyncRenderComponent(comp, expr.slice(1), env, ns);
if (comp && comp._macro) {
var expanded = trampoline(expandMacro(comp, expr.slice(1), env));
return asyncRenderToDom(expanded, env, ns);
}
}
// Macro
if (env[hname] && env[hname]._macro) {
var mac = env[hname];
var expanded = trampoline(expandMacro(mac, expr.slice(1), env));
return asyncRenderToDom(expanded, env, ns);
}
// HTML tag
if (typeof renderDomElement === "function" && contains(HTML_TAGS, hname)) {
return asyncRenderElement(hname, expr.slice(1), env, ns);
}
// html: prefix
if (hname.indexOf("html:") === 0) {
return asyncRenderElement(hname.slice(5), expr.slice(1), env, ns);
}
// Custom element
if (hname.indexOf("-") >= 0 && expr.length > 1 && expr[1] && expr[1]._kw) {
return asyncRenderElement(hname, expr.slice(1), env, ns);
}
// SVG context
if (ns) return asyncRenderElement(hname, expr.slice(1), env, ns);
// Fallback: eval and render
var fResult = asyncEval(expr, env);
if (isPromise(fResult)) return fResult.then(function(v) { return asyncRenderToDom(v, env, ns); });
return asyncRenderToDom(fResult, env, ns);
}
// Non-symbol head: eval call
var cResult = asyncEval(expr, env);
if (isPromise(cResult)) return cResult.then(function(v) { return asyncRenderToDom(v, env, ns); });
return asyncRenderToDom(cResult, env, ns);
}
function asyncRenderChildren(exprs, env, ns) {
var frag = document.createDocumentFragment();
var pending = [];
for (var i = 0; i < exprs.length; i++) {
var result = asyncRenderToDom(exprs[i], env, ns);
if (isPromise(result)) {
// Insert placeholder, replace when resolved
var placeholder = document.createComment("async");
frag.appendChild(placeholder);
(function(ph) {
pending.push(result.then(function(node) {
if (node) ph.parentNode.replaceChild(node, ph);
else ph.parentNode.removeChild(ph);
}));
})(placeholder);
} else if (result) {
frag.appendChild(result);
}
}
if (pending.length > 0) {
return Promise.all(pending).then(function() { return frag; });
}
return frag;
}
function asyncRenderElement(tag, args, env, ns) {
var newNs = tag === "svg" ? SVG_NS : tag === "math" ? MATH_NS : ns;
var el = domCreateElement(tag, newNs);
var pending = [];
var isVoid = contains(VOID_ELEMENTS, tag);
for (var i = 0; i < args.length; i++) {
var arg = args[i];
if (arg && arg._kw && (i + 1) < args.length) {
var attrName = arg.name;
var attrVal = asyncEval(args[i + 1], env);
i++;
if (isPromise(attrVal)) {
(function(an, av) {
pending.push(av.then(function(v) {
if (!isNil(v) && v !== false) {
if (contains(BOOLEAN_ATTRS, an)) { if (isSxTruthy(v)) el.setAttribute(an, ""); }
else if (v === true) el.setAttribute(an, "");
else el.setAttribute(an, String(v));
}
}));
})(attrName, attrVal);
} else {
if (!isNil(attrVal) && attrVal !== false) {
if (attrName === "class" && attrVal && attrVal._styleValue) {
el.setAttribute("class", (el.getAttribute("class") || "") + " " + attrVal.className);
} else if (attrName === "style" && attrVal && attrVal._styleValue) {
el.setAttribute("class", (el.getAttribute("class") || "") + " " + attrVal.className);
} else if (contains(BOOLEAN_ATTRS, attrName)) {
if (isSxTruthy(attrVal)) el.setAttribute(attrName, "");
} else if (attrVal === true) {
el.setAttribute(attrName, "");
} else {
el.setAttribute(attrName, String(attrVal));
}
}
}
} else if (!isVoid) {
var child = asyncRenderToDom(arg, env, newNs);
if (isPromise(child)) {
var placeholder = document.createComment("async");
el.appendChild(placeholder);
(function(ph) {
pending.push(child.then(function(node) {
if (node) ph.parentNode.replaceChild(node, ph);
else ph.parentNode.removeChild(ph);
}));
})(placeholder);
} else if (child) {
el.appendChild(child);
}
}
}
if (pending.length > 0) return Promise.all(pending).then(function() { return el; });
return el;
}
function asyncRenderComponent(comp, args, env, ns) {
var kwargs = {};
var children = [];
var pending = [];
for (var i = 0; i < args.length; i++) {
var arg = args[i];
if (arg && arg._kw && (i + 1) < args.length) {
var kName = arg.name;
var kVal = asyncEval(args[i + 1], env);
if (isPromise(kVal)) {
(function(k) { pending.push(kVal.then(function(v) { kwargs[k] = v; })); })(kName);
} else {
kwargs[kName] = kVal;
}
i++;
} else {
children.push(arg);
}
}
function doRender() {
var local = Object.create(componentClosure(comp));
for (var k in env) if (env.hasOwnProperty(k)) local[k] = env[k];
var params = componentParams(comp);
for (var j = 0; j < params.length; j++) {
local[params[j]] = params[j] in kwargs ? kwargs[params[j]] : NIL;
}
if (componentHasChildren(comp)) {
var childResult = asyncRenderChildren(children, env, ns);
if (isPromise(childResult)) {
return childResult.then(function(childFrag) {
local["children"] = childFrag;
return asyncRenderToDom(componentBody(comp), local, ns);
});
}
local["children"] = childResult;
}
return asyncRenderToDom(componentBody(comp), local, ns);
}
if (pending.length > 0) return Promise.all(pending).then(doRender);
return doRender();
}
function asyncRenderIf(expr, env, ns) {
var cond = asyncEval(expr[1], env);
if (isPromise(cond)) {
return cond.then(function(v) {
return isSxTruthy(v)
? asyncRenderToDom(expr[2], env, ns)
: (expr.length > 3 ? asyncRenderToDom(expr[3], env, ns) : null);
});
}
return isSxTruthy(cond)
? asyncRenderToDom(expr[2], env, ns)
: (expr.length > 3 ? asyncRenderToDom(expr[3], env, ns) : null);
}
function asyncRenderWhen(expr, env, ns) {
var cond = asyncEval(expr[1], env);
if (isPromise(cond)) {
return cond.then(function(v) {
return isSxTruthy(v) ? asyncRenderChildren(expr.slice(2), env, ns) : null;
});
}
return isSxTruthy(cond) ? asyncRenderChildren(expr.slice(2), env, ns) : null;
}
function asyncRenderCond(expr, env, ns) {
var clauses = expr.slice(1);
function step(idx) {
if (idx >= clauses.length) return null;
var clause = clauses[idx];
if (!Array.isArray(clause) || clause.length < 2) return step(idx + 1);
var test = clause[0];
if ((test && test._sym && (test.name === "else" || test.name === ":else")) ||
(test && test._kw && test.name === "else")) {
return asyncRenderToDom(clause[1], env, ns);
}
var v = asyncEval(test, env);
if (isPromise(v)) return v.then(function(r) { return isSxTruthy(r) ? asyncRenderToDom(clause[1], env, ns) : step(idx + 1); });
return isSxTruthy(v) ? asyncRenderToDom(clause[1], env, ns) : step(idx + 1);
}
return step(0);
}
function asyncRenderCase(expr, env, ns) {
var matchVal = asyncEval(expr[1], env);
function doCase(mv) {
var clauses = expr.slice(2);
for (var i = 0; i < clauses.length - 1; i += 2) {
var test = clauses[i];
if ((test && test._kw && test.name === "else") ||
(test && test._sym && (test.name === "else" || test.name === ":else"))) {
return asyncRenderToDom(clauses[i + 1], env, ns);
}
var tv = trampoline(evalExpr(test, env));
if (mv === tv || (typeof mv === "string" && typeof tv === "string" && mv === tv)) {
return asyncRenderToDom(clauses[i + 1], env, ns);
}
}
return null;
}
if (isPromise(matchVal)) return matchVal.then(doCase);
return doCase(matchVal);
}
function asyncRenderLet(expr, env, ns) {
var bindings = expr[1];
var local = Object.create(env);
for (var k in env) if (env.hasOwnProperty(k)) local[k] = env[k];
function bindStep(idx) {
if (!Array.isArray(bindings)) return asyncRenderChildren(expr.slice(2), local, ns);
// Nested pairs: ((a 1) (b 2))
if (bindings.length > 0 && Array.isArray(bindings[0])) {
if (idx >= bindings.length) return asyncRenderChildren(expr.slice(2), local, ns);
var b = bindings[idx];
var vname = b[0]._sym ? b[0].name : String(b[0]);
var val = asyncEval(b[1], local);
if (isPromise(val)) return val.then(function(v) { local[vname] = v; return bindStep(idx + 1); });
local[vname] = val;
return bindStep(idx + 1);
}
// Flat pairs: (a 1 b 2)
if (idx >= bindings.length) return asyncRenderChildren(expr.slice(2), local, ns);
var vn = bindings[idx]._sym ? bindings[idx].name : String(bindings[idx]);
var vv = asyncEval(bindings[idx + 1], local);
if (isPromise(vv)) return vv.then(function(v) { local[vn] = v; return bindStep(idx + 2); });
local[vn] = vv;
return bindStep(idx + 2);
}
return bindStep(0);
}
function asyncRenderMap(expr, env, ns) {
var fn = asyncEval(expr[1], env);
var coll = asyncEval(expr[2], env);
function doMap(f, c) {
if (!Array.isArray(c)) return null;
var frag = document.createDocumentFragment();
var pending = [];
for (var i = 0; i < c.length; i++) {
var item = c[i];
var result;
if (f && f._lambda) {
var lenv = Object.create(f._closure || env);
for (var k in env) if (env.hasOwnProperty(k)) lenv[k] = env[k];
lenv[f._params[0]] = item;
result = asyncRenderToDom(f._body, lenv, null);
} else if (typeof f === "function") {
var r = f(item);
result = isPromise(r) ? r.then(function(v) { return asyncRenderToDom(v, env, null); }) : asyncRenderToDom(r, env, null);
} else {
result = asyncRenderToDom(item, env, null);
}
if (isPromise(result)) {
var ph = document.createComment("async");
frag.appendChild(ph);
(function(p) { pending.push(result.then(function(n) { if (n) p.parentNode.replaceChild(n, p); else p.parentNode.removeChild(p); })); })(ph);
} else if (result) {
frag.appendChild(result);
}
}
if (pending.length) return Promise.all(pending).then(function() { return frag; });
return frag;
}
if (isPromise(fn) || isPromise(coll)) {
return Promise.all([isPromise(fn) ? fn : Promise.resolve(fn), isPromise(coll) ? coll : Promise.resolve(coll)])
.then(function(r) { return doMap(r[0], r[1]); });
}
return doMap(fn, coll);
}
function asyncRenderMapIndexed(expr, env, ns) {
var fn = asyncEval(expr[1], env);
var coll = asyncEval(expr[2], env);
function doMap(f, c) {
if (!Array.isArray(c)) return null;
var frag = document.createDocumentFragment();
var pending = [];
for (var i = 0; i < c.length; i++) {
var item = c[i];
var result;
if (f && f._lambda) {
var lenv = Object.create(f._closure || env);
for (var k in env) if (env.hasOwnProperty(k)) lenv[k] = env[k];
lenv[f._params[0]] = i;
lenv[f._params[1]] = item;
result = asyncRenderToDom(f._body, lenv, null);
} else if (typeof f === "function") {
var r = f(i, item);
result = isPromise(r) ? r.then(function(v) { return asyncRenderToDom(v, env, null); }) : asyncRenderToDom(r, env, null);
} else {
result = asyncRenderToDom(item, env, null);
}
if (isPromise(result)) {
var ph = document.createComment("async");
frag.appendChild(ph);
(function(p) { pending.push(result.then(function(n) { if (n) p.parentNode.replaceChild(n, p); else p.parentNode.removeChild(p); })); })(ph);
} else if (result) {
frag.appendChild(result);
}
}
if (pending.length) return Promise.all(pending).then(function() { return frag; });
return frag;
}
if (isPromise(fn) || isPromise(coll)) {
return Promise.all([isPromise(fn) ? fn : Promise.resolve(fn), isPromise(coll) ? coll : Promise.resolve(coll)])
.then(function(r) { return doMap(r[0], r[1]); });
}
return doMap(fn, coll);
}
function asyncEvalRaw(args, env) {
var parts = [];
var pending = [];
for (var i = 0; i < args.length; i++) {
var val = asyncEval(args[i], env);
if (isPromise(val)) {
(function(idx) {
pending.push(val.then(function(v) { parts[idx] = v; }));
})(parts.length);
parts.push(null);
} else {
parts.push(val);
}
}
function assemble() {
var html = "";
for (var j = 0; j < parts.length; j++) {
var p = parts[j];
if (p && p._rawHtml) html += p.html;
else if (typeof p === "string") html += p;
else if (p != null && !isNil(p)) html += String(p);
}
var el = document.createElement("span");
el.innerHTML = html;
var frag = document.createDocumentFragment();
while (el.firstChild) frag.appendChild(el.firstChild);
return frag;
}
if (pending.length) return Promise.all(pending).then(assemble);
return assemble();
}
// Async version of sxRenderWithEnv — returns Promise<DocumentFragment>
function asyncSxRenderWithEnv(source, extraEnv) {
var env = extraEnv ? merge(componentEnv, extraEnv) : componentEnv;
var exprs = parse(source);
if (!_hasDom) return Promise.resolve(null);
return asyncRenderChildren(exprs, env, null);
}
// Register a server-proxied IO primitive: fetches from /sx/io/<name>
function registerProxiedIo(name) {
registerIoPrimitive(name, function(args, kwargs) {
var url = "/sx/io/" + encodeURIComponent(name);
var qs = [];
for (var i = 0; i < args.length; i++) {
qs.push("_arg" + i + "=" + encodeURIComponent(String(args[i])));
}
for (var k in kwargs) {
if (kwargs.hasOwnProperty(k)) {
qs.push(encodeURIComponent(k) + "=" + encodeURIComponent(String(kwargs[k])));
}
}
if (qs.length) url += "?" + qs.join("&");
return fetch(url, { headers: { "SX-Request": "true" } })
.then(function(resp) {
if (!resp.ok) {
logWarn("sx:io " + name + " failed " + resp.status);
return NIL;
}
return resp.text();
})
.then(function(text) {
if (!text || text === "nil") return NIL;
try {
var exprs = parse(text);
return exprs.length === 1 ? exprs[0] : exprs;
} catch (e) {
logWarn("sx:io " + name + " parse error: " + (e && e.message ? e.message : e));
return NIL;
}
});
});
}
// Register default proxied IO primitives
function initIoPrimitives() {
var defaults = [
"highlight", "current-user", "request-arg", "request-path",
"app-url", "asset-url", "config"
];
for (var i = 0; i < defaults.length; i++) {
registerProxiedIo(defaults[i]);
}
logInfo("sx:io registered " + defaults.length + " proxied primitives");
}
// Parser — compiled from parser.sx (see PLATFORM_PARSER_JS for ident char classes)
var parse = sxParse;
@@ -4007,20 +4440,13 @@ callExpr.push(dictGet(kwargs, k)); } }
renderComponent: typeof sxRenderComponent === "function" ? sxRenderComponent : null,
getEnv: function() { return componentEnv; },
init: typeof bootInit === "function" ? bootInit : null,
scanRefs: scanRefs,
transitiveDeps: transitiveDeps,
computeAllDeps: computeAllDeps,
componentsNeeded: componentsNeeded,
pageComponentBundle: pageComponentBundle,
pageCssClasses: pageCssClasses,
scanIoRefs: scanIoRefs,
transitiveIoRefs: transitiveIoRefs,
computeAllIoRefs: computeAllIoRefs,
componentPure_p: componentPure_p,
splitPathSegments: splitPathSegments,
parseRoutePattern: parseRoutePattern,
matchRoute: matchRoute,
findMatchingRoute: findMatchingRoute,
registerIo: typeof registerIoPrimitive === "function" ? registerIoPrimitive : null,
asyncRender: typeof asyncSxRenderWithEnv === "function" ? asyncSxRenderWithEnv : null,
asyncRenderToDom: typeof asyncRenderToDom === "function" ? asyncRenderToDom : null,
_version: "ref-2.0 (boot+cssx+dom+engine+html+orchestration+parser+sx, bootstrap-compiled)"
};

View File

@@ -691,6 +691,15 @@ def _build_pages_sx(service: str) -> str:
deps = components_needed(content_src, _COMPONENT_ENV)
deps_sx = "(" + " ".join(_sx_literal(d) for d in sorted(deps)) + ")"
# Check if any dep component has IO refs (needs async rendering)
from .types import Component as _Comp
has_io = "false"
for dep_name in deps:
comp = _COMPONENT_ENV.get(dep_name)
if isinstance(comp, _Comp) and comp.io_refs:
has_io = "true"
break
# Build closure as SX dict
closure_parts: list[str] = []
for k, v in page_def.closure.items():
@@ -703,6 +712,7 @@ def _build_pages_sx(service: str) -> str:
+ " :path " + _sx_literal(page_def.path)
+ " :auth " + _sx_literal(auth)
+ " :has-data " + has_data
+ " :has-io " + has_io
+ " :content " + _sx_literal(content_src)
+ " :deps " + deps_sx
+ " :closure " + closure_sx + "}"

View File

@@ -349,23 +349,15 @@ def components_for_page(page_sx: str, service: str | None = None) -> tuple[str,
needed = components_needed(page_sx, _COMPONENT_ENV)
# Include deps for :data pages whose component trees are fully pure
# (no IO refs). Pages with IO deps must render server-side.
# Include deps for all :data pages so the client can render them.
# Pages with IO deps use the async render path (Phase 5) — the IO
# primitives are proxied via /sx/io/<name>.
if service:
from .pages import get_all_pages
for page_def in get_all_pages(service).values():
if page_def.data_expr is not None and page_def.content_expr is not None:
content_src = serialize(page_def.content_expr)
data_deps = components_needed(content_src, _COMPONENT_ENV)
# Check if any dep component has IO refs
has_io = False
for dep_name in data_deps:
comp = _COMPONENT_ENV.get(dep_name)
if isinstance(comp, Component) and comp.io_refs:
has_io = True
break
if not has_io:
needed |= data_deps
needed |= components_needed(content_src, _COMPONENT_ENV)
if not needed:
return "", ""
@@ -416,13 +408,7 @@ def css_classes_for_page(page_sx: str, service: str | None = None) -> set[str]:
for page_def in get_all_pages(service).values():
if page_def.data_expr is not None and page_def.content_expr is not None:
content_src = serialize(page_def.content_expr)
data_deps = components_needed(content_src, _COMPONENT_ENV)
has_io = any(
isinstance(_COMPONENT_ENV.get(d), Component) and _COMPONENT_ENV.get(d).io_refs
for d in data_deps
)
if not has_io:
needed |= data_deps
needed |= components_needed(content_src, _COMPONENT_ENV)
classes: set[str] = set()
for key, val in _COMPONENT_ENV.items():

View File

@@ -331,6 +331,9 @@ def auto_mount_pages(app: Any, service_name: str) -> None:
if has_data_pages:
auto_mount_page_data(app, service_name)
# Mount IO proxy endpoint for Phase 5: client-side IO primitives
mount_io_endpoint(app, service_name)
def mount_pages(bp: Any, service_name: str,
names: set[str] | list[str] | None = None) -> None:
@@ -535,3 +538,73 @@ def auto_mount_page_data(app: Any, service_name: str) -> None:
methods=["GET"],
)
logger.info("Mounted page data endpoint for %s at /sx/data/<page_name>", service_name)
def mount_io_endpoint(app: Any, service_name: str) -> None:
"""Mount /sx/io/<name> endpoint for client-side IO primitive calls.
The client can call any allowed IO primitive or page helper via GET/POST.
Result is returned as SX wire format (text/sx).
Falls back to page helpers when the name isn't a global IO primitive,
so service-specific functions like ``highlight`` work via the proxy.
"""
import asyncio as _asyncio
from quart import make_response, request, abort as quart_abort
from .primitives_io import IO_PRIMITIVES, execute_io
from .jinja_bridge import _get_request_context
from .parser import serialize
# Allowlist of IO primitives + page helpers the client may call
_ALLOWED_IO = {
"highlight", "current-user", "request-arg", "request-path",
"htmx-request?", "app-url", "asset-url", "config",
}
async def io_proxy(name: str) -> Any:
if name not in _ALLOWED_IO:
quart_abort(403)
# Parse args from query string or JSON body
args: list = []
kwargs: dict = {}
if request.method == "GET":
for k, v in request.args.items():
if k.startswith("_arg"):
args.append(v)
else:
kwargs[k] = v
else:
data = await request.get_json(silent=True) or {}
args = data.get("args", [])
kwargs = data.get("kwargs", {})
# Try global IO primitives first
if name in IO_PRIMITIVES:
ctx = _get_request_context()
result = await execute_io(name, args, kwargs, ctx)
else:
# Fall back to page helpers (service-specific functions)
helpers = get_page_helpers(service_name)
helper_fn = helpers.get(name)
if helper_fn is None:
quart_abort(404)
result = helper_fn(*args, **kwargs) if kwargs else helper_fn(*args)
if _asyncio.iscoroutine(result):
result = await result
result_sx = serialize(result) if result is not None else "nil"
resp = await make_response(result_sx, 200)
resp.content_type = "text/sx; charset=utf-8"
return resp
io_proxy.__name__ = "sx_io_proxy"
io_proxy.__qualname__ = "sx_io_proxy"
app.add_url_rule(
"/sx/io/<name>",
endpoint="sx_io_proxy",
view_func=io_proxy,
methods=["GET", "POST"],
)
logger.info("Mounted IO proxy endpoint for %s at /sx/io/<name>", service_name)

View File

@@ -344,6 +344,7 @@
(log-info (str "sx-browser " SX_VERSION))
(init-css-tracking)
(init-style-dict)
(init-io-primitives)
(process-page-scripts)
(process-sx-scripts nil)
(sx-hydrate-elements nil)

View File

@@ -384,6 +384,7 @@ class JSEmitter:
"bind-client-route-click": "bindClientRouteClick",
"try-client-route": "tryClientRoute",
"try-eval-content": "tryEvalContent",
"try-async-eval-content": "tryAsyncEvalContent",
"url-pathname": "urlPathname",
"bind-inline-handler": "bindInlineHandler",
"bind-preload": "bindPreload",
@@ -476,6 +477,7 @@ class JSEmitter:
"process-sx-scripts": "processSxScripts",
"process-component-script": "processComponentScript",
"init-style-dict": "initStyleDict",
"init-io-primitives": "initIoPrimitives",
"SX_VERSION": "SX_VERSION",
"boot-init": "bootInit",
"resolve-mount-target": "resolveMountTarget",
@@ -1144,6 +1146,607 @@ CONTINUATIONS_JS = '''
'''
ASYNC_IO_JS = '''
// =========================================================================
// Async IO: Promise-aware rendering for client-side IO primitives
// =========================================================================
//
// IO primitives (query, current-user, etc.) return Promises on the client.
// asyncRenderToDom walks the component tree; when it encounters an IO
// primitive, it awaits the Promise and continues rendering.
//
// The sync evaluator/renderer is untouched. This is a separate async path
// used only when a page's component tree contains IO references.
var IO_PRIMITIVES = {};
function registerIoPrimitive(name, fn) {
IO_PRIMITIVES[name] = fn;
}
function isPromise(x) {
return x != null && typeof x === "object" && typeof x.then === "function";
}
// Async trampoline: resolves thunks, awaits Promises
function asyncTrampoline(val) {
if (isPromise(val)) return val.then(asyncTrampoline);
if (isThunk(val)) return asyncTrampoline(evalExpr(thunkExpr(val), thunkEnv(val)));
return val;
}
// Async eval: like trampoline(evalExpr(...)) but handles IO primitives
function asyncEval(expr, env) {
// Intercept IO primitive calls at the AST level
if (Array.isArray(expr) && expr.length > 0) {
var head = expr[0];
if (head && head._sym) {
var name = head.name;
if (IO_PRIMITIVES[name]) {
// Evaluate args, then call the IO primitive
return asyncEvalIoCall(name, expr.slice(1), env);
}
}
}
// Non-IO: use sync eval, but result might be a thunk
var result = evalExpr(expr, env);
return asyncTrampoline(result);
}
function asyncEvalIoCall(name, rawArgs, env) {
// Parse keyword args and positional args, evaluating each (may be async)
var kwargs = {};
var args = [];
var promises = [];
var i = 0;
while (i < rawArgs.length) {
var arg = rawArgs[i];
if (arg && arg._kw && (i + 1) < rawArgs.length) {
var kName = arg.name;
var kVal = asyncEval(rawArgs[i + 1], env);
if (isPromise(kVal)) {
(function(k) { promises.push(kVal.then(function(v) { kwargs[k] = v; })); })(kName);
} else {
kwargs[kName] = kVal;
}
i += 2;
} else {
var aVal = asyncEval(arg, env);
if (isPromise(aVal)) {
(function(idx) { promises.push(aVal.then(function(v) { args[idx] = v; })); })(args.length);
args.push(null); // placeholder
} else {
args.push(aVal);
}
i++;
}
}
var ioFn = IO_PRIMITIVES[name];
if (promises.length > 0) {
return Promise.all(promises).then(function() { return ioFn(args, kwargs); });
}
return ioFn(args, kwargs);
}
// Async render-to-dom: returns Promise<Node> or Node
function asyncRenderToDom(expr, env, ns) {
// Literals
if (expr === NIL || expr === null || expr === undefined) return null;
if (expr === true || expr === false) return null;
if (typeof expr === "string") return document.createTextNode(expr);
if (typeof expr === "number") return document.createTextNode(String(expr));
// Symbol -> async eval then render
if (expr && expr._sym) {
var val = asyncEval(expr, env);
if (isPromise(val)) return val.then(function(v) { return asyncRenderToDom(v, env, ns); });
return asyncRenderToDom(val, env, ns);
}
// Keyword
if (expr && expr._kw) return document.createTextNode(expr.name);
// DocumentFragment / DOM nodes pass through
if (expr instanceof DocumentFragment || (expr && expr.nodeType)) return expr;
// Dict -> skip
if (expr && typeof expr === "object" && !Array.isArray(expr)) return null;
// List
if (!Array.isArray(expr) || expr.length === 0) return null;
var head = expr[0];
if (!head) return null;
// Symbol head
if (head._sym) {
var hname = head.name;
// IO primitive
if (IO_PRIMITIVES[hname]) {
var ioResult = asyncEval(expr, env);
if (isPromise(ioResult)) return ioResult.then(function(v) { return asyncRenderToDom(v, env, ns); });
return asyncRenderToDom(ioResult, env, ns);
}
// Fragment
if (hname === "<>") return asyncRenderChildren(expr.slice(1), env, ns);
// raw!
if (hname === "raw!") {
return asyncEvalRaw(expr.slice(1), env);
}
// Special forms that need async handling
if (hname === "if") return asyncRenderIf(expr, env, ns);
if (hname === "when") return asyncRenderWhen(expr, env, ns);
if (hname === "cond") return asyncRenderCond(expr, env, ns);
if (hname === "case") return asyncRenderCase(expr, env, ns);
if (hname === "let" || hname === "let*") return asyncRenderLet(expr, env, ns);
if (hname === "begin" || hname === "do") return asyncRenderChildren(expr.slice(1), env, ns);
if (hname === "map") return asyncRenderMap(expr, env, ns);
if (hname === "map-indexed") return asyncRenderMapIndexed(expr, env, ns);
if (hname === "for-each") return asyncRenderMap(expr, env, ns);
// define/defcomp/defmacro — eval for side effects
if (hname === "define" || hname === "defcomp" || hname === "defmacro" ||
hname === "defstyle" || hname === "defkeyframes" || hname === "defhandler") {
trampoline(evalExpr(expr, env));
return null;
}
// quote
if (hname === "quote") return null;
// lambda/fn
if (hname === "lambda" || hname === "fn") {
trampoline(evalExpr(expr, env));
return null;
}
// and/or — eval and render result
if (hname === "and" || hname === "or" || hname === "->") {
var aoResult = asyncEval(expr, env);
if (isPromise(aoResult)) return aoResult.then(function(v) { return asyncRenderToDom(v, env, ns); });
return asyncRenderToDom(aoResult, env, ns);
}
// set!
if (hname === "set!") {
asyncEval(expr, env);
return null;
}
// Component
if (hname.charAt(0) === "~") {
var comp = env[hname];
if (comp && comp._component) return asyncRenderComponent(comp, expr.slice(1), env, ns);
if (comp && comp._macro) {
var expanded = trampoline(expandMacro(comp, expr.slice(1), env));
return asyncRenderToDom(expanded, env, ns);
}
}
// Macro
if (env[hname] && env[hname]._macro) {
var mac = env[hname];
var expanded = trampoline(expandMacro(mac, expr.slice(1), env));
return asyncRenderToDom(expanded, env, ns);
}
// HTML tag
if (typeof renderDomElement === "function" && contains(HTML_TAGS, hname)) {
return asyncRenderElement(hname, expr.slice(1), env, ns);
}
// html: prefix
if (hname.indexOf("html:") === 0) {
return asyncRenderElement(hname.slice(5), expr.slice(1), env, ns);
}
// Custom element
if (hname.indexOf("-") >= 0 && expr.length > 1 && expr[1] && expr[1]._kw) {
return asyncRenderElement(hname, expr.slice(1), env, ns);
}
// SVG context
if (ns) return asyncRenderElement(hname, expr.slice(1), env, ns);
// Fallback: eval and render
var fResult = asyncEval(expr, env);
if (isPromise(fResult)) return fResult.then(function(v) { return asyncRenderToDom(v, env, ns); });
return asyncRenderToDom(fResult, env, ns);
}
// Non-symbol head: eval call
var cResult = asyncEval(expr, env);
if (isPromise(cResult)) return cResult.then(function(v) { return asyncRenderToDom(v, env, ns); });
return asyncRenderToDom(cResult, env, ns);
}
function asyncRenderChildren(exprs, env, ns) {
var frag = document.createDocumentFragment();
var pending = [];
for (var i = 0; i < exprs.length; i++) {
var result = asyncRenderToDom(exprs[i], env, ns);
if (isPromise(result)) {
// Insert placeholder, replace when resolved
var placeholder = document.createComment("async");
frag.appendChild(placeholder);
(function(ph) {
pending.push(result.then(function(node) {
if (node) ph.parentNode.replaceChild(node, ph);
else ph.parentNode.removeChild(ph);
}));
})(placeholder);
} else if (result) {
frag.appendChild(result);
}
}
if (pending.length > 0) {
return Promise.all(pending).then(function() { return frag; });
}
return frag;
}
function asyncRenderElement(tag, args, env, ns) {
var newNs = tag === "svg" ? SVG_NS : tag === "math" ? MATH_NS : ns;
var el = domCreateElement(tag, newNs);
var pending = [];
var isVoid = contains(VOID_ELEMENTS, tag);
for (var i = 0; i < args.length; i++) {
var arg = args[i];
if (arg && arg._kw && (i + 1) < args.length) {
var attrName = arg.name;
var attrVal = asyncEval(args[i + 1], env);
i++;
if (isPromise(attrVal)) {
(function(an, av) {
pending.push(av.then(function(v) {
if (!isNil(v) && v !== false) {
if (contains(BOOLEAN_ATTRS, an)) { if (isSxTruthy(v)) el.setAttribute(an, ""); }
else if (v === true) el.setAttribute(an, "");
else el.setAttribute(an, String(v));
}
}));
})(attrName, attrVal);
} else {
if (!isNil(attrVal) && attrVal !== false) {
if (attrName === "class" && attrVal && attrVal._styleValue) {
el.setAttribute("class", (el.getAttribute("class") || "") + " " + attrVal.className);
} else if (attrName === "style" && attrVal && attrVal._styleValue) {
el.setAttribute("class", (el.getAttribute("class") || "") + " " + attrVal.className);
} else if (contains(BOOLEAN_ATTRS, attrName)) {
if (isSxTruthy(attrVal)) el.setAttribute(attrName, "");
} else if (attrVal === true) {
el.setAttribute(attrName, "");
} else {
el.setAttribute(attrName, String(attrVal));
}
}
}
} else if (!isVoid) {
var child = asyncRenderToDom(arg, env, newNs);
if (isPromise(child)) {
var placeholder = document.createComment("async");
el.appendChild(placeholder);
(function(ph) {
pending.push(child.then(function(node) {
if (node) ph.parentNode.replaceChild(node, ph);
else ph.parentNode.removeChild(ph);
}));
})(placeholder);
} else if (child) {
el.appendChild(child);
}
}
}
if (pending.length > 0) return Promise.all(pending).then(function() { return el; });
return el;
}
function asyncRenderComponent(comp, args, env, ns) {
var kwargs = {};
var children = [];
var pending = [];
for (var i = 0; i < args.length; i++) {
var arg = args[i];
if (arg && arg._kw && (i + 1) < args.length) {
var kName = arg.name;
var kVal = asyncEval(args[i + 1], env);
if (isPromise(kVal)) {
(function(k) { pending.push(kVal.then(function(v) { kwargs[k] = v; })); })(kName);
} else {
kwargs[kName] = kVal;
}
i++;
} else {
children.push(arg);
}
}
function doRender() {
var local = Object.create(componentClosure(comp));
for (var k in env) if (env.hasOwnProperty(k)) local[k] = env[k];
var params = componentParams(comp);
for (var j = 0; j < params.length; j++) {
local[params[j]] = params[j] in kwargs ? kwargs[params[j]] : NIL;
}
if (componentHasChildren(comp)) {
var childResult = asyncRenderChildren(children, env, ns);
if (isPromise(childResult)) {
return childResult.then(function(childFrag) {
local["children"] = childFrag;
return asyncRenderToDom(componentBody(comp), local, ns);
});
}
local["children"] = childResult;
}
return asyncRenderToDom(componentBody(comp), local, ns);
}
if (pending.length > 0) return Promise.all(pending).then(doRender);
return doRender();
}
function asyncRenderIf(expr, env, ns) {
var cond = asyncEval(expr[1], env);
if (isPromise(cond)) {
return cond.then(function(v) {
return isSxTruthy(v)
? asyncRenderToDom(expr[2], env, ns)
: (expr.length > 3 ? asyncRenderToDom(expr[3], env, ns) : null);
});
}
return isSxTruthy(cond)
? asyncRenderToDom(expr[2], env, ns)
: (expr.length > 3 ? asyncRenderToDom(expr[3], env, ns) : null);
}
function asyncRenderWhen(expr, env, ns) {
var cond = asyncEval(expr[1], env);
if (isPromise(cond)) {
return cond.then(function(v) {
return isSxTruthy(v) ? asyncRenderChildren(expr.slice(2), env, ns) : null;
});
}
return isSxTruthy(cond) ? asyncRenderChildren(expr.slice(2), env, ns) : null;
}
function asyncRenderCond(expr, env, ns) {
var clauses = expr.slice(1);
function step(idx) {
if (idx >= clauses.length) return null;
var clause = clauses[idx];
if (!Array.isArray(clause) || clause.length < 2) return step(idx + 1);
var test = clause[0];
if ((test && test._sym && (test.name === "else" || test.name === ":else")) ||
(test && test._kw && test.name === "else")) {
return asyncRenderToDom(clause[1], env, ns);
}
var v = asyncEval(test, env);
if (isPromise(v)) return v.then(function(r) { return isSxTruthy(r) ? asyncRenderToDom(clause[1], env, ns) : step(idx + 1); });
return isSxTruthy(v) ? asyncRenderToDom(clause[1], env, ns) : step(idx + 1);
}
return step(0);
}
function asyncRenderCase(expr, env, ns) {
var matchVal = asyncEval(expr[1], env);
function doCase(mv) {
var clauses = expr.slice(2);
for (var i = 0; i < clauses.length - 1; i += 2) {
var test = clauses[i];
if ((test && test._kw && test.name === "else") ||
(test && test._sym && (test.name === "else" || test.name === ":else"))) {
return asyncRenderToDom(clauses[i + 1], env, ns);
}
var tv = trampoline(evalExpr(test, env));
if (mv === tv || (typeof mv === "string" && typeof tv === "string" && mv === tv)) {
return asyncRenderToDom(clauses[i + 1], env, ns);
}
}
return null;
}
if (isPromise(matchVal)) return matchVal.then(doCase);
return doCase(matchVal);
}
function asyncRenderLet(expr, env, ns) {
var bindings = expr[1];
var local = Object.create(env);
for (var k in env) if (env.hasOwnProperty(k)) local[k] = env[k];
function bindStep(idx) {
if (!Array.isArray(bindings)) return asyncRenderChildren(expr.slice(2), local, ns);
// Nested pairs: ((a 1) (b 2))
if (bindings.length > 0 && Array.isArray(bindings[0])) {
if (idx >= bindings.length) return asyncRenderChildren(expr.slice(2), local, ns);
var b = bindings[idx];
var vname = b[0]._sym ? b[0].name : String(b[0]);
var val = asyncEval(b[1], local);
if (isPromise(val)) return val.then(function(v) { local[vname] = v; return bindStep(idx + 1); });
local[vname] = val;
return bindStep(idx + 1);
}
// Flat pairs: (a 1 b 2)
if (idx >= bindings.length) return asyncRenderChildren(expr.slice(2), local, ns);
var vn = bindings[idx]._sym ? bindings[idx].name : String(bindings[idx]);
var vv = asyncEval(bindings[idx + 1], local);
if (isPromise(vv)) return vv.then(function(v) { local[vn] = v; return bindStep(idx + 2); });
local[vn] = vv;
return bindStep(idx + 2);
}
return bindStep(0);
}
function asyncRenderMap(expr, env, ns) {
var fn = asyncEval(expr[1], env);
var coll = asyncEval(expr[2], env);
function doMap(f, c) {
if (!Array.isArray(c)) return null;
var frag = document.createDocumentFragment();
var pending = [];
for (var i = 0; i < c.length; i++) {
var item = c[i];
var result;
if (f && f._lambda) {
var lenv = Object.create(f._closure || env);
for (var k in env) if (env.hasOwnProperty(k)) lenv[k] = env[k];
lenv[f._params[0]] = item;
result = asyncRenderToDom(f._body, lenv, null);
} else if (typeof f === "function") {
var r = f(item);
result = isPromise(r) ? r.then(function(v) { return asyncRenderToDom(v, env, null); }) : asyncRenderToDom(r, env, null);
} else {
result = asyncRenderToDom(item, env, null);
}
if (isPromise(result)) {
var ph = document.createComment("async");
frag.appendChild(ph);
(function(p) { pending.push(result.then(function(n) { if (n) p.parentNode.replaceChild(n, p); else p.parentNode.removeChild(p); })); })(ph);
} else if (result) {
frag.appendChild(result);
}
}
if (pending.length) return Promise.all(pending).then(function() { return frag; });
return frag;
}
if (isPromise(fn) || isPromise(coll)) {
return Promise.all([isPromise(fn) ? fn : Promise.resolve(fn), isPromise(coll) ? coll : Promise.resolve(coll)])
.then(function(r) { return doMap(r[0], r[1]); });
}
return doMap(fn, coll);
}
function asyncRenderMapIndexed(expr, env, ns) {
var fn = asyncEval(expr[1], env);
var coll = asyncEval(expr[2], env);
function doMap(f, c) {
if (!Array.isArray(c)) return null;
var frag = document.createDocumentFragment();
var pending = [];
for (var i = 0; i < c.length; i++) {
var item = c[i];
var result;
if (f && f._lambda) {
var lenv = Object.create(f._closure || env);
for (var k in env) if (env.hasOwnProperty(k)) lenv[k] = env[k];
lenv[f._params[0]] = i;
lenv[f._params[1]] = item;
result = asyncRenderToDom(f._body, lenv, null);
} else if (typeof f === "function") {
var r = f(i, item);
result = isPromise(r) ? r.then(function(v) { return asyncRenderToDom(v, env, null); }) : asyncRenderToDom(r, env, null);
} else {
result = asyncRenderToDom(item, env, null);
}
if (isPromise(result)) {
var ph = document.createComment("async");
frag.appendChild(ph);
(function(p) { pending.push(result.then(function(n) { if (n) p.parentNode.replaceChild(n, p); else p.parentNode.removeChild(p); })); })(ph);
} else if (result) {
frag.appendChild(result);
}
}
if (pending.length) return Promise.all(pending).then(function() { return frag; });
return frag;
}
if (isPromise(fn) || isPromise(coll)) {
return Promise.all([isPromise(fn) ? fn : Promise.resolve(fn), isPromise(coll) ? coll : Promise.resolve(coll)])
.then(function(r) { return doMap(r[0], r[1]); });
}
return doMap(fn, coll);
}
function asyncEvalRaw(args, env) {
var parts = [];
var pending = [];
for (var i = 0; i < args.length; i++) {
var val = asyncEval(args[i], env);
if (isPromise(val)) {
(function(idx) {
pending.push(val.then(function(v) { parts[idx] = v; }));
})(parts.length);
parts.push(null);
} else {
parts.push(val);
}
}
function assemble() {
var html = "";
for (var j = 0; j < parts.length; j++) {
var p = parts[j];
if (p && p._rawHtml) html += p.html;
else if (typeof p === "string") html += p;
else if (p != null && !isNil(p)) html += String(p);
}
var el = document.createElement("span");
el.innerHTML = html;
var frag = document.createDocumentFragment();
while (el.firstChild) frag.appendChild(el.firstChild);
return frag;
}
if (pending.length) return Promise.all(pending).then(assemble);
return assemble();
}
// Async version of sxRenderWithEnv — returns Promise<DocumentFragment>
function asyncSxRenderWithEnv(source, extraEnv) {
var env = extraEnv ? merge(componentEnv, extraEnv) : componentEnv;
var exprs = parse(source);
if (!_hasDom) return Promise.resolve(null);
return asyncRenderChildren(exprs, env, null);
}
// Register a server-proxied IO primitive: fetches from /sx/io/<name>
function registerProxiedIo(name) {
registerIoPrimitive(name, function(args, kwargs) {
var url = "/sx/io/" + encodeURIComponent(name);
var qs = [];
for (var i = 0; i < args.length; i++) {
qs.push("_arg" + i + "=" + encodeURIComponent(String(args[i])));
}
for (var k in kwargs) {
if (kwargs.hasOwnProperty(k)) {
qs.push(encodeURIComponent(k) + "=" + encodeURIComponent(String(kwargs[k])));
}
}
if (qs.length) url += "?" + qs.join("&");
return fetch(url, { headers: { "SX-Request": "true" } })
.then(function(resp) {
if (!resp.ok) {
logWarn("sx:io " + name + " failed " + resp.status);
return NIL;
}
return resp.text();
})
.then(function(text) {
if (!text || text === "nil") return NIL;
try {
var exprs = parse(text);
return exprs.length === 1 ? exprs[0] : exprs;
} catch (e) {
logWarn("sx:io " + name + " parse error: " + (e && e.message ? e.message : e));
return NIL;
}
});
});
}
// Register default proxied IO primitives
function initIoPrimitives() {
var defaults = [
"highlight", "current-user", "request-arg", "request-path",
"app-url", "asset-url", "config"
];
for (var i = 0; i < defaults.length; i++) {
registerProxiedIo(defaults[i]);
}
logInfo("sx:io registered " + defaults.length + " proxied primitives");
}
'''
def compile_ref_to_js(
adapters: list[str] | None = None,
modules: list[str] | None = None,
@@ -1290,6 +1893,8 @@ def compile_ref_to_js(
parts.append(fixups_js(has_html, has_sx, has_dom))
if has_continuations:
parts.append(CONTINUATIONS_JS)
if has_dom:
parts.append(ASYNC_IO_JS)
parts.append(public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_cssx, has_boot, has_parser, adapter_label, has_deps, has_router))
parts.append(EPILOGUE)
from datetime import datetime, timezone
@@ -2783,6 +3388,32 @@ PLATFORM_ORCHESTRATION_JS = """
}
}
// Async eval with callback — used for pages with IO deps.
// Calls callback(rendered) when done, callback(null) on failure.
function tryAsyncEvalContent(source, env, callback) {
var merged = merge(componentEnv);
if (env && !isNil(env)) {
var ks = Object.keys(env);
for (var i = 0; i < ks.length; i++) merged[ks[i]] = env[ks[i]];
}
try {
var result = asyncSxRenderWithEnv(source, merged);
if (isPromise(result)) {
result.then(function(rendered) {
callback(rendered);
}).catch(function(e) {
logInfo("sx:async eval miss: " + (e && e.message ? e.message : e));
callback(null);
});
} else {
callback(result);
}
} catch (e) {
logInfo("sx:async eval miss: " + (e && e.message ? e.message : e));
callback(null);
}
}
function resolvePageData(pageName, params, callback) {
// Platform implementation: fetch page data via HTTP from /sx/data/ endpoint.
// The spec only knows about resolve-page-data(name, params, callback) —
@@ -3360,6 +3991,10 @@ def public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_cssx, has
api_lines.append(' matchRoute: matchRoute,')
api_lines.append(' findMatchingRoute: findMatchingRoute,')
if has_dom:
api_lines.append(' registerIo: typeof registerIoPrimitive === "function" ? registerIoPrimitive : null,')
api_lines.append(' asyncRender: typeof asyncSxRenderWithEnv === "function" ? asyncSxRenderWithEnv : null,')
api_lines.append(' asyncRenderToDom: typeof asyncRenderToDom === "function" ? asyncRenderToDom : null,')
api_lines.append(f' _version: "{version}"')
api_lines.append(' };')
api_lines.append('')

View File

@@ -648,40 +648,71 @@
(do (log-warn (str "sx:route target not found: " target-sel)) false)
(if (not (deps-satisfied? match))
(do (log-info (str "sx:route deps miss for " page-name)) false)
(if (get match "has-data")
;; Data page: check cache, else resolve asynchronously
(let ((cache-key (page-data-cache-key page-name params))
(cached (page-data-cache-get cache-key)))
(if cached
;; Cache hit: render immediately
(let ((env (merge closure params cached))
(let ((has-io (get match "has-io")))
(if (get match "has-data")
;; Data page: check cache, else resolve asynchronously
(let ((cache-key (page-data-cache-key page-name params))
(cached (page-data-cache-get cache-key)))
(if cached
;; Cache hit
(let ((env (merge closure params cached)))
(if has-io
;; Async render (data+IO)
(do
(log-info (str "sx:route client+cache+async " pathname))
(try-async-eval-content content-src env
(fn (rendered)
(if (nil? rendered)
(log-warn (str "sx:route async eval failed for " pathname))
(swap-rendered-content target rendered pathname))))
true)
;; Sync render (data only)
(let ((rendered (try-eval-content content-src env)))
(if (nil? rendered)
(do (log-warn (str "sx:route cached eval failed for " pathname)) false)
(do
(log-info (str "sx:route client+cache " pathname))
(swap-rendered-content target rendered pathname)
true)))))
;; Cache miss: fetch, cache, render
(do
(log-info (str "sx:route client+data " pathname))
(resolve-page-data page-name params
(fn (data)
(page-data-cache-set cache-key data)
(let ((env (merge closure params data)))
(if has-io
;; Async render (data+IO)
(try-async-eval-content content-src env
(fn (rendered)
(if (nil? rendered)
(log-warn (str "sx:route data+async eval failed for " pathname))
(swap-rendered-content target rendered pathname))))
;; Sync render (data only)
(let ((rendered (try-eval-content content-src env)))
(if (nil? rendered)
(log-warn (str "sx:route data eval failed for " pathname))
(swap-rendered-content target rendered pathname)))))))
true)))
;; Non-data page
(if has-io
;; Async render (IO only, no data)
(do
(log-info (str "sx:route client+async " pathname))
(try-async-eval-content content-src (merge closure params)
(fn (rendered)
(if (nil? rendered)
(log-warn (str "sx:route async eval failed for " pathname))
(swap-rendered-content target rendered pathname))))
true)
;; Pure page: render immediately
(let ((env (merge closure params))
(rendered (try-eval-content content-src env)))
(if (nil? rendered)
(do (log-warn (str "sx:route cached eval failed for " pathname)) false)
(do (log-info (str "sx:route server (eval failed) " pathname)) false)
(do
(log-info (str "sx:route client+cache " pathname))
(swap-rendered-content target rendered pathname)
true)))
;; Cache miss: fetch, cache, render
(do
(log-info (str "sx:route client+data " pathname))
(resolve-page-data page-name params
(fn (data)
(page-data-cache-set cache-key data)
(let ((env (merge closure params data))
(rendered (try-eval-content content-src env)))
(if (nil? rendered)
(log-warn (str "sx:route data eval failed for " pathname))
(swap-rendered-content target rendered pathname)))))
true)))
;; Pure page: render immediately
(let ((env (merge closure params))
(rendered (try-eval-content content-src env)))
(if (nil? rendered)
(do (log-info (str "sx:route server (eval failed) " pathname)) false)
(do
(swap-rendered-content target rendered pathname)
true)))))))))))))
true)))))))))))))))
(define bind-client-route-link
@@ -991,6 +1022,8 @@
;;
;; === Client-side routing ===
;; (try-eval-content source env) → DOM node or nil (catches eval errors)
;; (try-async-eval-content source env callback) → void; async render,
;; calls (callback rendered-or-nil). Used for pages with IO deps.
;; (url-pathname href) → extract pathname from URL string
;; (resolve-page-data name params cb) → void; resolves data for a named page.
;; Platform decides transport (HTTP, cache, IPC, etc). Calls (cb data-dict)

62
sx/sx/async-io-demo.sx Normal file
View File

@@ -0,0 +1,62 @@
;; Async IO demo — Phase 5 client-side rendering with IO primitives.
;;
;; This component calls `highlight` inline — an IO primitive that runs
;; server-side Python (pygments). When rendered on the server, it
;; executes synchronously. When rendered client-side, the async
;; renderer proxies the call via /sx/io/highlight and awaits the result.
;;
;; Open browser console and look for:
;; "sx:route client+async" — async render with IO proxy
;; "sx:io registered N proxied primitives" — IO proxy initialization
(defcomp ~async-io-demo-content ()
(div :class "space-y-8"
(div :class "border-b border-stone-200 pb-6"
(h1 :class "text-2xl font-bold text-stone-900" "Async IO Demo")
(p :class "mt-2 text-stone-600"
"This page calls " (code :class "bg-stone-100 px-1 rounded text-violet-700" "highlight")
" inline — an IO primitive. On the server it runs Python (pygments). "
"On the client it proxies via " (code :class "bg-stone-100 px-1 rounded text-violet-700" "/sx/io/highlight")
" and the async renderer awaits the result."))
;; Live syntax-highlighted code blocks — each is an IO call
(div :class "space-y-6"
(h2 :class "text-lg font-semibold text-stone-800" "Live IO: syntax highlighting")
(div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3"
(h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "SX component definition")
(div :class "rounded bg-stone-900 p-4 text-sm overflow-x-auto"
(raw! (highlight "(defcomp ~card (&key title subtitle &rest children)\n (div :class \"border rounded-lg p-4 shadow-sm\"\n (h2 :class \"text-lg font-bold\" title)\n (when subtitle\n (p :class \"text-stone-500 text-sm\" subtitle))\n (div :class \"mt-3\" children)))" "lisp"))))
(div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3"
(h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "Python server code")
(div :class "rounded bg-stone-900 p-4 text-sm overflow-x-auto"
(raw! (highlight "from shared.sx.pages import mount_io_endpoint\n\n# The IO proxy serves any allowed primitive:\n# GET /sx/io/highlight?_arg0=code&_arg1=lisp\nasync def io_proxy(name):\n result = await execute_io(name, args, kwargs, ctx)\n return serialize(result)" "python"))))
(div :class "rounded-lg border border-stone-200 bg-white p-5 space-y-3"
(h3 :class "text-sm font-medium text-stone-500 uppercase tracking-wide" "JavaScript async renderer")
(div :class "rounded bg-stone-900 p-4 text-sm overflow-x-auto"
(raw! (highlight "// The async renderer intercepts IO primitive calls\nfunction asyncEval(expr, env) {\n if (IO_PRIMITIVES[head.name]) {\n return asyncEvalIoCall(name, args, env);\n }\n return asyncTrampoline(evalExpr(expr, env));\n}" "javascript")))))
;; Architecture explanation
(div :class "rounded-lg border border-blue-200 bg-blue-50 p-5 space-y-3"
(h2 :class "text-lg font-semibold text-blue-900" "How it works")
(ol :class "list-decimal list-inside text-blue-800 space-y-2 text-sm"
(li "Server renders the page — " (code "highlight") " runs Python pygments directly")
(li "Client receives page with component definitions including " (code "~async-io-demo-content"))
(li "On client navigation, " (code "has-io") " flag routes to async renderer")
(li "Async renderer encounters " (code "(highlight ...)") " — checks " (code "IO_PRIMITIVES"))
(li "Proxied call: " (code "fetch(\"/sx/io/highlight?_arg0=...&_arg1=lisp\")"))
(li "Server executes, returns SX wire format (quoted HTML string)")
(li "Async renderer inserts result via " (code "(raw! ...)") " — renders identically")))
;; Verification instructions
(div :class "rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm space-y-2"
(p :class "font-semibold text-amber-800" "How to verify async IO rendering")
(ol :class "list-decimal list-inside text-amber-700 space-y-1"
(li "Open the browser console (F12)")
(li "Navigate to another page (e.g. Data Test)")
(li "Click back to this page")
(li "Look for: " (code :class "bg-amber-100 px-1 rounded" "sx:route client+async /isomorphism/async-io"))
(li "The code blocks should render identically — same syntax highlighting")
(li "Check Network tab: you'll see 3 requests to " (code :class "bg-amber-100 px-1 rounded" "/sx/io/highlight"))))))

View File

@@ -107,7 +107,8 @@
(dict :label "Roadmap" :href "/isomorphism/")
(dict :label "Bundle Analyzer" :href "/isomorphism/bundle-analyzer")
(dict :label "Routing Analyzer" :href "/isomorphism/routing-analyzer")
(dict :label "Data Test" :href "/isomorphism/data-test")))
(dict :label "Data Test" :href "/isomorphism/data-test")
(dict :label "Async IO" :href "/isomorphism/async-io")))
(define plans-nav-items (list
(dict :label "Reader Macros" :href "/plans/reader-macros"

View File

@@ -444,6 +444,17 @@
:server-time server-time :items items
:phase phase :transport transport))
(defpage async-io-demo
:path "/isomorphism/async-io"
:auth :public
:layout (:sx-section
:section "Isomorphism"
:sub-label "Isomorphism"
:sub-href "/isomorphism/"
:sub-nav (~section-nav :items isomorphism-nav-items :current "Async IO")
:selected "Async IO")
:content (~async-io-demo-content))
;; Wildcard must come AFTER specific routes (first-match routing)
(defpage isomorphism-page
:path "/isomorphism/<slug>"