Compare commits
9 Commits
bec0397c3c
...
3574f7e163
| Author | SHA1 | Date | |
|---|---|---|---|
| 3574f7e163 | |||
| 6312eb66a2 | |||
| 917a487195 | |||
| 605aafa2eb | |||
| 7f466f0fd6 | |||
| 6421a23223 | |||
| 342da2bd44 | |||
| a05d642461 | |||
| 1fe258e3f7 |
@@ -14,7 +14,7 @@
|
||||
// =========================================================================
|
||||
|
||||
var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } });
|
||||
var SX_VERSION = "2026-03-07T09:51:42Z";
|
||||
var SX_VERSION = "2026-03-07T17:50:50Z";
|
||||
|
||||
function isNil(x) { return x === NIL || x === null || x === undefined; }
|
||||
function isSxTruthy(x) { return x !== false && !isNil(x); }
|
||||
@@ -565,6 +565,92 @@
|
||||
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
|
||||
// =========================================================================
|
||||
@@ -2367,6 +2453,21 @@ allKf = concat(allKf, styleValueKeyframes_(sv)); } }
|
||||
processElements(el);
|
||||
return sxHydrateElements(el);
|
||||
})() : NIL);
|
||||
})(); };
|
||||
|
||||
// resolve-suspense
|
||||
var resolveSuspense = function(id, sx) { return (function() {
|
||||
var el = domQuery((String("[data-suspense=\"") + String(id) + String("\"]")));
|
||||
return (isSxTruthy(el) ? (function() {
|
||||
var ast = parse(sx);
|
||||
var env = getRenderEnv(NIL);
|
||||
var node = renderToDom(ast, env, NIL);
|
||||
domSetTextContent(el, "");
|
||||
domAppend(el, node);
|
||||
processElements(el);
|
||||
sxHydrateElements(el);
|
||||
return domDispatch(el, "sx:resolved", {"id": id});
|
||||
})() : logWarn((String("resolveSuspense: no element for id=") + String(id))));
|
||||
})(); };
|
||||
|
||||
// sx-hydrate-elements
|
||||
@@ -2483,6 +2584,119 @@ callExpr.push(dictGet(kwargs, k)); } }
|
||||
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)); };
|
||||
|
||||
|
||||
// === Transpiled from router (client-side route matching) ===
|
||||
|
||||
// split-path-segments
|
||||
@@ -4480,6 +4694,12 @@ callExpr.push(dictGet(kwargs, k)); } }
|
||||
renderToSx: function(expr, env) { return renderToSx(expr, env || merge(componentEnv)); },
|
||||
renderToDom: _hasDom ? function(expr, env, ns) { return renderToDom(expr, env || merge(componentEnv), ns || null); } : null,
|
||||
parseTriggerSpec: typeof parseTriggerSpec === "function" ? parseTriggerSpec : null,
|
||||
parseTime: typeof parseTime === "function" ? parseTime : null,
|
||||
defaultTrigger: typeof defaultTrigger === "function" ? defaultTrigger : null,
|
||||
parseSwapSpec: typeof parseSwapSpec === "function" ? parseSwapSpec : null,
|
||||
parseRetrySpec: typeof parseRetrySpec === "function" ? parseRetrySpec : null,
|
||||
nextRetryMs: typeof nextRetryMs === "function" ? nextRetryMs : null,
|
||||
filterParams: typeof filterParams === "function" ? filterParams : null,
|
||||
morphNode: typeof morphNode === "function" ? morphNode : null,
|
||||
morphChildren: typeof morphChildren === "function" ? morphChildren : null,
|
||||
swapDomNodes: typeof swapDomNodes === "function" ? swapDomNodes : null,
|
||||
@@ -4492,7 +4712,19 @@ callExpr.push(dictGet(kwargs, k)); } }
|
||||
update: typeof sxUpdateElement === "function" ? sxUpdateElement : null,
|
||||
renderComponent: typeof sxRenderComponent === "function" ? sxRenderComponent : null,
|
||||
getEnv: function() { return componentEnv; },
|
||||
resolveSuspense: typeof resolveSuspense === "function" ? resolveSuspense : null,
|
||||
init: typeof bootInit === "function" ? bootInit : null,
|
||||
scanRefs: scanRefs,
|
||||
scanComponentsFromSource: scanComponentsFromSource,
|
||||
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,
|
||||
@@ -4514,7 +4746,18 @@ callExpr.push(dictGet(kwargs, k)); } }
|
||||
|
||||
// --- Auto-init ---
|
||||
if (typeof document !== "undefined") {
|
||||
var _sxInit = function() { bootInit(); };
|
||||
var _sxInit = function() {
|
||||
bootInit();
|
||||
// Process any suspense resolutions that arrived before init
|
||||
if (global.__sxPending) {
|
||||
for (var pi = 0; pi < global.__sxPending.length; pi++) {
|
||||
resolveSuspense(global.__sxPending[pi].id, global.__sxPending[pi].sx);
|
||||
}
|
||||
global.__sxPending = null;
|
||||
}
|
||||
// Set up direct resolution for future chunks
|
||||
global.__sxResolve = function(id, sx) { resolveSuspense(id, sx); };
|
||||
};
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", _sxInit);
|
||||
} else {
|
||||
|
||||
@@ -131,6 +131,35 @@
|
||||
}
|
||||
}
|
||||
|
||||
function loadDepsFromBootstrap(env) {
|
||||
if (Sx.scanRefs) {
|
||||
env["scan-refs"] = Sx.scanRefs;
|
||||
env["scan-components-from-source"] = Sx.scanComponentsFromSource;
|
||||
env["transitive-deps"] = Sx.transitiveDeps;
|
||||
env["compute-all-deps"] = Sx.computeAllDeps;
|
||||
env["components-needed"] = Sx.componentsNeeded;
|
||||
env["page-component-bundle"] = Sx.pageComponentBundle;
|
||||
env["page-css-classes"] = Sx.pageCssClasses;
|
||||
env["scan-io-refs"] = Sx.scanIoRefs;
|
||||
env["transitive-io-refs"] = Sx.transitiveIoRefs;
|
||||
env["compute-all-io-refs"] = Sx.computeAllIoRefs;
|
||||
env["component-pure?"] = Sx.componentPure_p;
|
||||
env["test-env"] = function() { return env; };
|
||||
}
|
||||
}
|
||||
|
||||
function loadEngineFromBootstrap(env) {
|
||||
if (Sx.parseTime) {
|
||||
env["parse-time"] = Sx.parseTime;
|
||||
env["parse-trigger-spec"] = Sx.parseTriggerSpec;
|
||||
env["default-trigger"] = Sx.defaultTrigger;
|
||||
env["parse-swap-spec"] = Sx.parseSwapSpec;
|
||||
env["parse-retry-spec"] = Sx.parseRetrySpec;
|
||||
env["next-retry-ms"] = function(cur, cap) { return Math.min(cur * 2, cap); };
|
||||
env["filter-params"] = Sx.filterParams;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Legacy runner (monolithic test.sx) ---
|
||||
window.sxRunTests = function(srcId, outId, btnId) {
|
||||
var src = document.getElementById(srcId).textContent;
|
||||
@@ -169,6 +198,8 @@
|
||||
"parser": { needs: ["sx-parse"] },
|
||||
"router": { needs: [] },
|
||||
"render": { needs: ["render-html"] },
|
||||
"deps": { needs: [] },
|
||||
"engine": { needs: [] },
|
||||
};
|
||||
|
||||
window.sxRunModularTests = function(specName, outId, btnId) {
|
||||
@@ -190,8 +221,10 @@
|
||||
var sn = specs[si];
|
||||
if (!SPECS[sn]) continue;
|
||||
|
||||
// Load router from bootstrap if needed
|
||||
// Load module functions from bootstrap
|
||||
if (sn === "router") loadRouterFromBootstrap(ctx.env);
|
||||
if (sn === "deps") loadDepsFromBootstrap(ctx.env);
|
||||
if (sn === "engine") loadEngineFromBootstrap(ctx.env);
|
||||
|
||||
// Find spec source — either per-spec textarea or embedded in overview
|
||||
var specEl = document.getElementById("test-spec-" + sn);
|
||||
|
||||
@@ -1588,6 +1588,32 @@
|
||||
isTruthy: isSxTruthy,
|
||||
isNil: isNil,
|
||||
|
||||
/**
|
||||
* Resolve a streaming suspense placeholder.
|
||||
* Called by inline <script> tags that arrive during chunked transfer:
|
||||
* __sxResolve("content", "(~article :title \"Hello\")")
|
||||
*
|
||||
* Finds the suspense wrapper by data-suspense attribute, renders the
|
||||
* new SX content, and replaces the wrapper's children.
|
||||
*/
|
||||
resolveSuspense: function (id, sx) {
|
||||
var el = document.querySelector('[data-suspense="' + id + '"]');
|
||||
if (!el) {
|
||||
console.warn("[sx] resolveSuspense: no element for id=" + id);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var node = Sx.render(sx);
|
||||
el.textContent = "";
|
||||
el.appendChild(node);
|
||||
if (typeof SxEngine !== "undefined") SxEngine.process(el);
|
||||
Sx.hydrate(el);
|
||||
el.dispatchEvent(new CustomEvent("sx:resolved", { bubbles: true, detail: { id: id } }));
|
||||
} catch (e) {
|
||||
console.error("[sx] resolveSuspense error for id=" + id, e);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Mount a sx expression into a DOM element, replacing its contents.
|
||||
* Sx.mount(el, '(~card :title "Hi")')
|
||||
@@ -3164,6 +3190,15 @@
|
||||
Sx.processScripts();
|
||||
Sx.hydrate();
|
||||
SxEngine.process();
|
||||
// Process any streaming suspense resolutions that arrived before init
|
||||
if (global.__sxPending) {
|
||||
for (var pi = 0; pi < global.__sxPending.length; pi++) {
|
||||
Sx.resolveSuspense(global.__sxPending[pi].id, global.__sxPending[pi].sx);
|
||||
}
|
||||
global.__sxPending = null;
|
||||
}
|
||||
// Replace bootstrap resolver with direct calls
|
||||
global.__sxResolve = function (id, sx) { Sx.resolveSuspense(id, sx); };
|
||||
};
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", init);
|
||||
|
||||
@@ -983,6 +983,13 @@ def _sf_defpage(expr: list, env: dict) -> PageDef:
|
||||
if isinstance(cache_result, dict):
|
||||
cache = cache_result
|
||||
|
||||
# Stream — evaluate (it's a static boolean)
|
||||
stream_val = slots.get("stream")
|
||||
stream = False
|
||||
if stream_val is not None:
|
||||
stream_result = _trampoline(_eval(stream_val, env))
|
||||
stream = bool(stream_result)
|
||||
|
||||
page = PageDef(
|
||||
name=name_sym.name,
|
||||
path=path,
|
||||
@@ -994,6 +1001,8 @@ def _sf_defpage(expr: list, env: dict) -> PageDef:
|
||||
filter_expr=slots.get("filter"),
|
||||
aside_expr=slots.get("aside"),
|
||||
menu_expr=slots.get("menu"),
|
||||
stream=stream,
|
||||
fallback_expr=slots.get("fallback"),
|
||||
closure=dict(env),
|
||||
)
|
||||
env[f"page:{name_sym.name}"] = page
|
||||
|
||||
@@ -704,6 +704,31 @@ def _build_pages_sx(service: str) -> str:
|
||||
if io_deps else "()"
|
||||
)
|
||||
|
||||
# Extract layout identity for client-side routing.
|
||||
# When layout changes between pages, client routing falls through
|
||||
# to server so OOB header updates are applied.
|
||||
layout_id = ""
|
||||
if isinstance(page_def.layout, str):
|
||||
layout_id = page_def.layout
|
||||
elif isinstance(page_def.layout, list):
|
||||
from .types import Keyword as _Kw, Symbol as _Sym
|
||||
first = page_def.layout[0]
|
||||
if isinstance(first, _Kw):
|
||||
layout_id = first.name
|
||||
elif isinstance(first, _Sym):
|
||||
layout_id = first.name
|
||||
else:
|
||||
layout_id = str(first)
|
||||
# Append section kwarg to distinguish same-layout-type
|
||||
# with different sections (e.g. sx-section+Docs vs sx-section+Testing)
|
||||
raw_layout = page_def.layout
|
||||
for li in range(1, len(raw_layout) - 1):
|
||||
if isinstance(raw_layout[li], _Kw) and raw_layout[li].name == "section":
|
||||
val = raw_layout[li + 1]
|
||||
if val is not None:
|
||||
layout_id = f"{layout_id}:{val}"
|
||||
break
|
||||
|
||||
# Build closure as SX dict
|
||||
closure_parts: list[str] = []
|
||||
for k, v in page_def.closure.items():
|
||||
@@ -711,11 +736,15 @@ def _build_pages_sx(service: str) -> str:
|
||||
closure_parts.append(f":{k} {_sx_literal(v)}")
|
||||
closure_sx = "{" + " ".join(closure_parts) + "}"
|
||||
|
||||
stream = "true" if page_def.stream else "false"
|
||||
|
||||
entry = (
|
||||
"{:name " + _sx_literal(page_def.name)
|
||||
+ " :path " + _sx_literal(page_def.path)
|
||||
+ " :auth " + _sx_literal(auth)
|
||||
+ " :has-data " + has_data
|
||||
+ " :stream " + stream
|
||||
+ " :layout " + _sx_literal(layout_id)
|
||||
+ " :io-deps " + io_deps_sx
|
||||
+ " :content " + _sx_literal(content_src)
|
||||
+ " :deps " + deps_sx
|
||||
@@ -826,6 +855,127 @@ def sx_page(ctx: dict, page_sx: str, *,
|
||||
)
|
||||
|
||||
|
||||
_SX_STREAMING_RESOLVE = """\
|
||||
<script>window.__sxResolve&&window.__sxResolve({id},{sx})</script>"""
|
||||
|
||||
_SX_STREAMING_BOOTSTRAP = """\
|
||||
<script>window.__sxPending=[];window.__sxResolve=function(i,s){\
|
||||
if(window.Sx&&Sx.resolveSuspense){Sx.resolveSuspense(i,s)}\
|
||||
else{window.__sxPending.push({id:i,sx:s})}}</script>"""
|
||||
|
||||
|
||||
def sx_page_streaming_parts(ctx: dict, page_sx: str, *,
|
||||
meta_html: str = "") -> tuple[str, str]:
|
||||
"""Split the page into shell (before scripts) and tail (scripts).
|
||||
|
||||
Returns (shell, tail) where:
|
||||
shell = everything up to and including the page SX mount script
|
||||
tail = the suspense bootstrap + sx-browser.js + body.js scripts
|
||||
|
||||
For streaming, the caller yields shell first, then resolution chunks,
|
||||
then tail to close the document.
|
||||
"""
|
||||
from .jinja_bridge import components_for_page, css_classes_for_page
|
||||
from .css_registry import lookup_rules, get_preamble, registry_loaded, store_css_hash
|
||||
|
||||
from quart import current_app as _ca
|
||||
component_defs, component_hash = components_for_page(page_sx, service=_ca.name)
|
||||
|
||||
client_hash = _get_sx_comp_cookie()
|
||||
if not _is_dev_mode() and client_hash and client_hash == component_hash:
|
||||
component_defs = ""
|
||||
|
||||
sx_css = ""
|
||||
sx_css_classes = ""
|
||||
if registry_loaded():
|
||||
classes = css_classes_for_page(page_sx, service=_ca.name)
|
||||
classes.update(["bg-stone-50", "text-stone-900"])
|
||||
rules = lookup_rules(classes)
|
||||
sx_css = get_preamble() + rules
|
||||
sx_css_classes = store_css_hash(classes)
|
||||
|
||||
asset_url = get_asset_url(ctx)
|
||||
title = ctx.get("base_title", "Rose Ash")
|
||||
csrf = _get_csrf_token()
|
||||
|
||||
if _is_dev_mode() and page_sx and page_sx.startswith("("):
|
||||
from .parser import parse as _parse, serialize as _serialize
|
||||
try:
|
||||
page_sx = _serialize(_parse(page_sx), pretty=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
styles_hash = _get_style_dict_hash()
|
||||
client_styles_hash = _get_sx_styles_cookie()
|
||||
styles_json = "" if (not _is_dev_mode() and client_styles_hash == styles_hash) else _build_style_dict_json()
|
||||
|
||||
import logging
|
||||
from quart import current_app
|
||||
pages_sx = _build_pages_sx(current_app.name)
|
||||
|
||||
sx_js_hash = _script_hash("sx-browser.js")
|
||||
body_js_hash = _script_hash("body.js")
|
||||
|
||||
# Shell: everything up to and including the page SX
|
||||
shell = (
|
||||
'<!doctype html>\n<html lang="en">\n<head>\n'
|
||||
'<meta charset="utf-8">\n'
|
||||
'<meta name="viewport" content="width=device-width, initial-scale=1">\n'
|
||||
'<meta name="robots" content="index,follow">\n'
|
||||
'<meta name="theme-color" content="#ffffff">\n'
|
||||
f'<title>{_html_escape(title)}</title>\n'
|
||||
f'{meta_html}'
|
||||
'<style>@media (min-width: 768px) { .js-mobile-sentinel { display:none !important; } }</style>\n'
|
||||
f'<meta name="csrf-token" content="{_html_escape(csrf)}">\n'
|
||||
f'<style id="sx-css">{sx_css}</style>\n'
|
||||
f'<meta name="sx-css-classes" content="{sx_css_classes}">\n'
|
||||
'<script src="https://unpkg.com/prismjs/prism.js"></script>\n'
|
||||
'<script src="https://unpkg.com/prismjs/components/prism-javascript.min.js"></script>\n'
|
||||
'<script src="https://unpkg.com/prismjs/components/prism-python.min.js"></script>\n'
|
||||
'<script src="https://unpkg.com/prismjs/components/prism-bash.min.js"></script>\n'
|
||||
'<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>\n'
|
||||
"<script>if(matchMedia('(hover:hover) and (pointer:fine)').matches){document.documentElement.classList.add('hover-capable')}</script>\n"
|
||||
"<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>\n"
|
||||
'<style>\n'
|
||||
'details[data-toggle-group="mobile-panels"]>summary{list-style:none}\n'
|
||||
'details[data-toggle-group="mobile-panels"]>summary::-webkit-details-marker{display:none}\n'
|
||||
'@media(min-width:768px){.nav-group:focus-within .submenu,.nav-group:hover .submenu{display:block}}\n'
|
||||
'img{max-width:100%;height:auto}\n'
|
||||
'.clamp-2{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}\n'
|
||||
'.clamp-3{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}\n'
|
||||
'.no-scrollbar::-webkit-scrollbar{display:none}.no-scrollbar{-ms-overflow-style:none;scrollbar-width:none}\n'
|
||||
'details.group{overflow:hidden}details.group>summary{list-style:none}details.group>summary::-webkit-details-marker{display:none}\n'
|
||||
'.sx-indicator{display:none}.sx-request .sx-indicator{display:inline-flex}\n'
|
||||
'.sx-error .sx-indicator{display:none}.sx-loading .sx-indicator{display:inline-flex}\n'
|
||||
'.js-wrap.open .js-pop{display:block}.js-wrap.open .js-backdrop{display:block}\n'
|
||||
'</style>\n'
|
||||
'</head>\n'
|
||||
'<body class="bg-stone-50 text-stone-900">\n'
|
||||
f'<script type="text/sx-styles" data-hash="{styles_hash}">{styles_json}</script>\n'
|
||||
f'<script type="text/sx" data-components data-hash="{component_hash}">{component_defs}</script>\n'
|
||||
f'<script type="text/sx-pages">{pages_sx}</script>\n'
|
||||
f'<script type="text/sx" data-mount="body">{page_sx}</script>\n'
|
||||
)
|
||||
|
||||
# Tail: bootstrap suspense resolver + scripts + close
|
||||
tail = (
|
||||
_SX_STREAMING_BOOTSTRAP + '\n'
|
||||
f'<script src="{asset_url}/scripts/sx-browser.js?v={sx_js_hash}"></script>\n'
|
||||
f'<script src="{asset_url}/scripts/body.js?v={body_js_hash}"></script>\n'
|
||||
)
|
||||
|
||||
return shell, tail
|
||||
|
||||
|
||||
def sx_streaming_resolve_script(suspension_id: str, sx_source: str) -> str:
|
||||
"""Build a <script> tag that resolves a streaming suspense placeholder."""
|
||||
import json
|
||||
return _SX_STREAMING_RESOLVE.format(
|
||||
id=json.dumps(suspension_id),
|
||||
sx=json.dumps(sx_source),
|
||||
)
|
||||
|
||||
|
||||
_SCRIPT_HASH_CACHE: dict[str, str] = {}
|
||||
_STYLE_DICT_JSON: str = ""
|
||||
_STYLE_DICT_HASH: str = ""
|
||||
|
||||
@@ -309,6 +309,157 @@ async def execute_page(
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Streaming page execution (Phase 6: Streaming & Suspense)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def execute_page_streaming(
|
||||
page_def: PageDef,
|
||||
service_name: str,
|
||||
url_params: dict[str, Any] | None = None,
|
||||
):
|
||||
"""Execute a page with streaming response.
|
||||
|
||||
Returns an async generator that yields HTML chunks:
|
||||
1. HTML shell with suspense placeholders (immediate)
|
||||
2. Resolution <script> tags as IO completes
|
||||
3. Closing </body></html>
|
||||
|
||||
Each suspense placeholder renders a loading skeleton. As data and
|
||||
header IO resolve, the server streams inline scripts that call
|
||||
``__sxResolve(id, sx)`` to replace the placeholder content.
|
||||
"""
|
||||
import asyncio
|
||||
from .jinja_bridge import get_component_env, _get_request_context
|
||||
from .async_eval import async_eval
|
||||
from .page import get_template_context
|
||||
from .helpers import (
|
||||
_render_to_sx, sx_page_streaming_parts,
|
||||
sx_streaming_resolve_script,
|
||||
)
|
||||
from .parser import SxExpr, serialize as sx_serialize
|
||||
from .layouts import get_layout
|
||||
|
||||
if url_params is None:
|
||||
url_params = {}
|
||||
|
||||
env = dict(get_component_env())
|
||||
env.update(get_page_helpers(service_name))
|
||||
env.update(page_def.closure)
|
||||
for key, val in url_params.items():
|
||||
kebab = key.replace("_", "-")
|
||||
env[kebab] = val
|
||||
env[key] = val
|
||||
|
||||
ctx = _get_request_context()
|
||||
tctx = await get_template_context()
|
||||
|
||||
# Build fallback expressions
|
||||
if page_def.fallback_expr is not None:
|
||||
fallback_sx = sx_serialize(page_def.fallback_expr)
|
||||
else:
|
||||
fallback_sx = (
|
||||
'(div :class "p-8 animate-pulse"'
|
||||
' (div :class "h-8 bg-stone-200 rounded mb-4 w-1/3")'
|
||||
' (div :class "h-64 bg-stone-200 rounded"))'
|
||||
)
|
||||
header_fallback = '(div :class "h-12 bg-stone-200 animate-pulse")'
|
||||
|
||||
# Resolve layout
|
||||
layout = None
|
||||
layout_kwargs: dict[str, Any] = {}
|
||||
if page_def.layout is not None:
|
||||
if isinstance(page_def.layout, str):
|
||||
layout_name = page_def.layout
|
||||
elif isinstance(page_def.layout, list):
|
||||
from .types import Keyword as SxKeyword, Symbol as SxSymbol
|
||||
raw = page_def.layout
|
||||
first = raw[0]
|
||||
layout_name = (
|
||||
first.name if isinstance(first, (SxKeyword, SxSymbol))
|
||||
else str(first)
|
||||
)
|
||||
i = 1
|
||||
while i < len(raw):
|
||||
k = raw[i]
|
||||
if isinstance(k, SxKeyword) and i + 1 < len(raw):
|
||||
resolved = await async_eval(raw[i + 1], env, ctx)
|
||||
layout_kwargs[k.name.replace("-", "_")] = resolved
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
else:
|
||||
layout_name = str(page_def.layout)
|
||||
layout = get_layout(layout_name)
|
||||
|
||||
# --- Concurrent IO tasks ---
|
||||
|
||||
async def _eval_data_and_content():
|
||||
data_env = dict(env)
|
||||
if page_def.data_expr is not None:
|
||||
data_result = await async_eval(page_def.data_expr, data_env, ctx)
|
||||
if isinstance(data_result, dict):
|
||||
for k, v in data_result.items():
|
||||
data_env[k.replace("_", "-")] = v
|
||||
content_sx = await _eval_slot(page_def.content_expr, data_env, ctx) if page_def.content_expr else ""
|
||||
filter_sx = await _eval_slot(page_def.filter_expr, data_env, ctx) if page_def.filter_expr else ""
|
||||
aside_sx = await _eval_slot(page_def.aside_expr, data_env, ctx) if page_def.aside_expr else ""
|
||||
menu_sx = await _eval_slot(page_def.menu_expr, data_env, ctx) if page_def.menu_expr else ""
|
||||
return content_sx, filter_sx, aside_sx, menu_sx
|
||||
|
||||
async def _eval_headers():
|
||||
if layout is None:
|
||||
return "", ""
|
||||
rows = await layout.full_headers(tctx, **layout_kwargs)
|
||||
menu = await layout.mobile_menu(tctx, **layout_kwargs)
|
||||
return rows, menu
|
||||
|
||||
data_task = asyncio.create_task(_eval_data_and_content())
|
||||
header_task = asyncio.create_task(_eval_headers())
|
||||
|
||||
# --- Build initial page SX with suspense placeholders ---
|
||||
|
||||
initial_page_sx = await _render_to_sx("app-body",
|
||||
header_rows=SxExpr(
|
||||
f'(~suspense :id "stream-headers" :fallback {header_fallback})'
|
||||
),
|
||||
content=SxExpr(
|
||||
f'(~suspense :id "stream-content" :fallback {fallback_sx})'
|
||||
),
|
||||
)
|
||||
|
||||
shell, tail = sx_page_streaming_parts(tctx, initial_page_sx)
|
||||
|
||||
# --- Yield initial shell + scripts ---
|
||||
yield shell + tail
|
||||
|
||||
# --- Yield resolution chunks in completion order ---
|
||||
tasks = {data_task: "data", header_task: "headers"}
|
||||
pending = set(tasks.keys())
|
||||
|
||||
while pending:
|
||||
done, pending = await asyncio.wait(
|
||||
pending, return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
for task in done:
|
||||
label = tasks[task]
|
||||
try:
|
||||
result = task.result()
|
||||
except Exception as e:
|
||||
logger.error("Streaming %s task failed: %s", label, e)
|
||||
continue
|
||||
|
||||
if label == "data":
|
||||
content_sx, filter_sx, aside_sx, menu_sx = result
|
||||
yield sx_streaming_resolve_script("stream-content", content_sx)
|
||||
elif label == "headers":
|
||||
header_rows, header_menu = result
|
||||
if header_rows:
|
||||
yield sx_streaming_resolve_script("stream-headers", header_rows)
|
||||
|
||||
yield "\n</body>\n</html>"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Blueprint mounting
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -356,14 +507,27 @@ def mount_pages(bp: Any, service_name: str,
|
||||
|
||||
def _mount_one_page(bp: Any, service_name: str, page_def: PageDef) -> None:
|
||||
"""Mount a single PageDef as a GET route on the blueprint."""
|
||||
from quart import make_response
|
||||
from quart import make_response, Response
|
||||
|
||||
# Build the view function
|
||||
if page_def.stream:
|
||||
# Streaming response: yields HTML chunks as IO resolves
|
||||
async def page_view(**kwargs: Any) -> Any:
|
||||
from shared.browser.app.utils.htmx import is_htmx_request
|
||||
current = get_page(service_name, page_def.name) or page_def
|
||||
# Only stream for full page loads (not SX/HTMX requests)
|
||||
if is_htmx_request():
|
||||
result = await execute_page(current, service_name, url_params=kwargs)
|
||||
if hasattr(result, "status_code"):
|
||||
return result
|
||||
return await make_response(result, 200)
|
||||
# Streaming response
|
||||
gen = execute_page_streaming(current, service_name, url_params=kwargs)
|
||||
return Response(gen, content_type="text/html; charset=utf-8")
|
||||
else:
|
||||
# Standard non-streaming response
|
||||
async def page_view(**kwargs: Any) -> Any:
|
||||
# Re-fetch the page from registry to support hot-reload of content
|
||||
current = get_page(service_name, page_def.name) or page_def
|
||||
result = await execute_page(current, service_name, url_params=kwargs)
|
||||
# If result is already a Response (from sx_response), return it
|
||||
if hasattr(result, "status_code"):
|
||||
return result
|
||||
return await make_response(result, 200)
|
||||
|
||||
@@ -91,6 +91,32 @@
|
||||
(sx-hydrate-elements el))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Resolve Suspense — replace streaming placeholder with resolved content
|
||||
;; --------------------------------------------------------------------------
|
||||
;;
|
||||
;; Called by inline <script> tags that arrive during chunked transfer:
|
||||
;; __sxResolve("content", "(~article :title \"Hello\")")
|
||||
;;
|
||||
;; Finds the suspense wrapper by data-suspense attribute, renders the
|
||||
;; new SX content, and replaces the wrapper's children.
|
||||
|
||||
(define resolve-suspense
|
||||
(fn (id sx)
|
||||
(let ((el (dom-query (str "[data-suspense=\"" id "\"]"))))
|
||||
(if el
|
||||
(do
|
||||
(let ((ast (parse sx))
|
||||
(env (get-render-env nil))
|
||||
(node (render-to-dom ast env nil)))
|
||||
(dom-set-text-content el "")
|
||||
(dom-append el node)
|
||||
(process-elements el)
|
||||
(sx-hydrate-elements el)
|
||||
(dom-dispatch el "sx:resolved" {:id id})))
|
||||
(log-warn (str "resolveSuspense: no element for id=" id))))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Hydrate — render all [data-sx] elements
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
@@ -480,6 +480,7 @@ class JSEmitter:
|
||||
"init-style-dict": "initStyleDict",
|
||||
"SX_VERSION": "SX_VERSION",
|
||||
"boot-init": "bootInit",
|
||||
"resolve-suspense": "resolveSuspense",
|
||||
"resolve-mount-target": "resolveMountTarget",
|
||||
"sx-render-with-env": "sxRenderWithEnv",
|
||||
"get-render-env": "getRenderEnv",
|
||||
@@ -4006,6 +4007,12 @@ def public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_cssx, has
|
||||
api_lines.append(' renderToDom: _hasDom ? function(expr, env, ns) { return renderToDom(expr, env || merge(componentEnv), ns || null); } : null,')
|
||||
if has_engine:
|
||||
api_lines.append(' parseTriggerSpec: typeof parseTriggerSpec === "function" ? parseTriggerSpec : null,')
|
||||
api_lines.append(' parseTime: typeof parseTime === "function" ? parseTime : null,')
|
||||
api_lines.append(' defaultTrigger: typeof defaultTrigger === "function" ? defaultTrigger : null,')
|
||||
api_lines.append(' parseSwapSpec: typeof parseSwapSpec === "function" ? parseSwapSpec : null,')
|
||||
api_lines.append(' parseRetrySpec: typeof parseRetrySpec === "function" ? parseRetrySpec : null,')
|
||||
api_lines.append(' nextRetryMs: typeof nextRetryMs === "function" ? nextRetryMs : null,')
|
||||
api_lines.append(' filterParams: typeof filterParams === "function" ? filterParams : null,')
|
||||
api_lines.append(' morphNode: typeof morphNode === "function" ? morphNode : null,')
|
||||
api_lines.append(' morphChildren: typeof morphChildren === "function" ? morphChildren : null,')
|
||||
api_lines.append(' swapDomNodes: typeof swapDomNodes === "function" ? swapDomNodes : null,')
|
||||
@@ -4020,11 +4027,13 @@ def public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_cssx, has
|
||||
api_lines.append(' update: typeof sxUpdateElement === "function" ? sxUpdateElement : null,')
|
||||
api_lines.append(' renderComponent: typeof sxRenderComponent === "function" ? sxRenderComponent : null,')
|
||||
api_lines.append(' getEnv: function() { return componentEnv; },')
|
||||
api_lines.append(' resolveSuspense: typeof resolveSuspense === "function" ? resolveSuspense : null,')
|
||||
api_lines.append(' init: typeof bootInit === "function" ? bootInit : null,')
|
||||
elif has_orch:
|
||||
api_lines.append(' init: typeof engineInit === "function" ? engineInit : null,')
|
||||
if has_deps:
|
||||
api_lines.append(' scanRefs: scanRefs,')
|
||||
api_lines.append(' scanComponentsFromSource: scanComponentsFromSource,')
|
||||
api_lines.append(' transitiveDeps: transitiveDeps,')
|
||||
api_lines.append(' computeAllDeps: computeAllDeps,')
|
||||
api_lines.append(' componentsNeeded: componentsNeeded,')
|
||||
@@ -4060,7 +4069,18 @@ def public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_cssx, has
|
||||
api_lines.append('''
|
||||
// --- Auto-init ---
|
||||
if (typeof document !== "undefined") {
|
||||
var _sxInit = function() { bootInit(); };
|
||||
var _sxInit = function() {
|
||||
bootInit();
|
||||
// Process any suspense resolutions that arrived before init
|
||||
if (global.__sxPending) {
|
||||
for (var pi = 0; pi < global.__sxPending.length; pi++) {
|
||||
resolveSuspense(global.__sxPending[pi].id, global.__sxPending[pi].sx);
|
||||
}
|
||||
global.__sxPending = null;
|
||||
}
|
||||
// Set up direct resolution for future chunks
|
||||
global.__sxResolve = function(id, sx) { resolveSuspense(id, sx); };
|
||||
};
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", _sxInit);
|
||||
} else {
|
||||
|
||||
@@ -854,6 +854,7 @@ ADAPTER_FILES = {
|
||||
SPEC_MODULES = {
|
||||
"deps": ("deps.sx", "deps (component dependency analysis)"),
|
||||
"router": ("router.sx", "router (client-side route matching)"),
|
||||
"engine": ("engine.sx", "engine (fetch/swap/trigger pure logic)"),
|
||||
}
|
||||
|
||||
|
||||
@@ -958,7 +959,7 @@ def compile_ref_to_py(
|
||||
Valid names: continuations.
|
||||
None = no extensions.
|
||||
spec_modules: List of spec module names to include.
|
||||
Valid names: deps.
|
||||
Valid names: deps, engine.
|
||||
None = no spec modules.
|
||||
"""
|
||||
# Determine which primitive modules to include
|
||||
@@ -1832,9 +1833,14 @@ PRIMITIVES["parse-int"] = lambda v, d=0: _sx_parse_int(v, d)
|
||||
PRIMITIVES["parse-datetime"] = lambda s: str(s) if s else NIL
|
||||
|
||||
def _sx_parse_int(v, default=0):
|
||||
try:
|
||||
return _b_int(v)
|
||||
except (ValueError, TypeError):
|
||||
if v is None or v is NIL:
|
||||
return default
|
||||
s = str(v).strip()
|
||||
# Match JS parseInt: extract leading integer portion
|
||||
import re as _re
|
||||
m = _re.match(r'^[+-]?\\d+', s)
|
||||
if m:
|
||||
return _b_int(m.group())
|
||||
return default
|
||||
''',
|
||||
|
||||
@@ -1976,6 +1982,10 @@ concat = PRIMITIVES["concat"]
|
||||
split = PRIMITIVES["split"]
|
||||
length = PRIMITIVES["len"]
|
||||
merge = PRIMITIVES["merge"]
|
||||
trim = PRIMITIVES["trim"]
|
||||
replace = PRIMITIVES["replace"]
|
||||
parse_int = PRIMITIVES["parse-int"]
|
||||
upper = PRIMITIVES["upper"]
|
||||
'''
|
||||
|
||||
|
||||
@@ -2189,7 +2199,7 @@ def main():
|
||||
parser.add_argument(
|
||||
"--spec-modules",
|
||||
default=None,
|
||||
help="Comma-separated spec modules (deps). Default: none.",
|
||||
help="Comma-separated spec modules (deps,engine). Default: none.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
adapters = args.adapters.split(",") if args.adapters else None
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
;; ==========================================================================
|
||||
;; boundary-app.sx — Deployment-specific boundary declarations
|
||||
;;
|
||||
;; Layout context I/O primitives for THIS deployment's service architecture.
|
||||
;; I/O primitives specific to THIS deployment's architecture:
|
||||
;; inter-service communication, framework bindings, domain concepts,
|
||||
;; and layout context providers.
|
||||
;;
|
||||
;; These are NOT part of the SX language contract — a different deployment
|
||||
;; would declare different layout contexts here.
|
||||
;; would declare different primitives here.
|
||||
;;
|
||||
;; The core SX I/O contract lives in boundary.sx.
|
||||
;; Per-service page helpers live in {service}/sx/boundary.sx.
|
||||
@@ -11,7 +14,92 @@
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Layout context providers — deployment-specific I/O
|
||||
;; Inter-service communication — microservice architecture
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-io-primitive "frag"
|
||||
:params (service frag-type &key)
|
||||
:returns "string"
|
||||
:async true
|
||||
:doc "Fetch cross-service HTML fragment."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "query"
|
||||
:params (service query-name &key)
|
||||
:returns "any"
|
||||
:async true
|
||||
:doc "Fetch data from another service via internal HTTP."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "action"
|
||||
:params (service action-name &key)
|
||||
:returns "any"
|
||||
:async true
|
||||
:doc "Call an action on another service via internal HTTP."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "service"
|
||||
:params (service-or-method &rest args &key)
|
||||
:returns "any"
|
||||
:async true
|
||||
:doc "Call a domain service method. Two-arg: (service svc method). One-arg: (service method) uses bound handler service."
|
||||
:context :request)
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Framework bindings — Quart/Jinja2/HTMX specifics
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-io-primitive "htmx-request?"
|
||||
:params ()
|
||||
:returns "boolean"
|
||||
:async true
|
||||
:doc "True if current request has HX-Request header."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "g"
|
||||
:params (key)
|
||||
:returns "any"
|
||||
:async true
|
||||
:doc "Read a value from the Quart request-local g object."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "jinja-global"
|
||||
:params (key &rest default)
|
||||
:returns "any"
|
||||
:async false
|
||||
:doc "Read a Jinja environment global."
|
||||
:context :request)
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Domain concepts — navigation, relations
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(define-io-primitive "nav-tree"
|
||||
:params ()
|
||||
:returns "list"
|
||||
:async true
|
||||
:doc "Navigation tree as list of node dicts."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "get-children"
|
||||
:params (&key parent-type parent-id)
|
||||
:returns "list"
|
||||
:async true
|
||||
:doc "Fetch child entities for a parent."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "relations-from"
|
||||
:params (entity-type)
|
||||
:returns "list"
|
||||
:async false
|
||||
:doc "List of RelationDef dicts for an entity type."
|
||||
:context :config)
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Layout context providers — per-service header/page context
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
;; Shared across all services (root layout)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
;; This is the LANGUAGE contract — not deployment-specific.
|
||||
;;
|
||||
;; Pure primitives (Tier 1) are declared in primitives.sx.
|
||||
;; Deployment-specific I/O (layout contexts) lives in boundary-app.sx.
|
||||
;; Deployment-specific I/O lives in boundary-app.sx.
|
||||
;; Per-service page helpers live in {service}/sx/boundary.sx.
|
||||
;;
|
||||
;; Format:
|
||||
@@ -28,38 +28,11 @@
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Tier 2: Core I/O primitives — async, side-effectful, need host context
|
||||
;;
|
||||
;; These are generic web-platform I/O that any SX web host would provide,
|
||||
;; regardless of deployment architecture.
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
;; Cross-service communication
|
||||
|
||||
(define-io-primitive "frag"
|
||||
:params (service frag-type &key)
|
||||
:returns "string"
|
||||
:async true
|
||||
:doc "Fetch cross-service HTML fragment."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "query"
|
||||
:params (service query-name &key)
|
||||
:returns "any"
|
||||
:async true
|
||||
:doc "Fetch data from another service via internal HTTP."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "action"
|
||||
:params (service action-name &key)
|
||||
:returns "any"
|
||||
:async true
|
||||
:doc "Call an action on another service via internal HTTP."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "service"
|
||||
:params (service-or-method &rest args &key)
|
||||
:returns "any"
|
||||
:async true
|
||||
:doc "Call a domain service method. Two-arg: (service svc method). One-arg: (service method) uses bound handler service."
|
||||
:context :request)
|
||||
|
||||
;; Request context
|
||||
|
||||
(define-io-primitive "current-user"
|
||||
@@ -69,13 +42,6 @@
|
||||
:doc "Current authenticated user dict, or nil."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "htmx-request?"
|
||||
:params ()
|
||||
:returns "boolean"
|
||||
:async true
|
||||
:doc "True if current request has HX-Request header."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "request-arg"
|
||||
:params (name &rest default)
|
||||
:returns "any"
|
||||
@@ -97,13 +63,6 @@
|
||||
:doc "Read a URL view argument from the current request."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "g"
|
||||
:params (key)
|
||||
:returns "any"
|
||||
:async true
|
||||
:doc "Read a value from the Quart request-local g object."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "csrf-token"
|
||||
:params ()
|
||||
:returns "string"
|
||||
@@ -134,22 +93,6 @@
|
||||
:doc "Service URL prefix for dev/prod routing."
|
||||
:context :request)
|
||||
|
||||
;; Navigation and relations
|
||||
|
||||
(define-io-primitive "nav-tree"
|
||||
:params ()
|
||||
:returns "list"
|
||||
:async true
|
||||
:doc "Navigation tree as list of node dicts."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "get-children"
|
||||
:params (&key parent-type parent-id)
|
||||
:returns "list"
|
||||
:async true
|
||||
:doc "Fetch child entities for a parent."
|
||||
:context :request)
|
||||
|
||||
;; Config and host context (sync — no await needed)
|
||||
|
||||
(define-io-primitive "app-url"
|
||||
@@ -170,21 +113,7 @@
|
||||
:params (key)
|
||||
:returns "any"
|
||||
:async false
|
||||
:doc "Read a value from app-config.yaml."
|
||||
:context :config)
|
||||
|
||||
(define-io-primitive "jinja-global"
|
||||
:params (key &rest default)
|
||||
:returns "any"
|
||||
:async false
|
||||
:doc "Read a Jinja environment global."
|
||||
:context :request)
|
||||
|
||||
(define-io-primitive "relations-from"
|
||||
:params (entity-type)
|
||||
:returns "list"
|
||||
:async false
|
||||
:doc "List of RelationDef dicts for an entity type."
|
||||
:doc "Read a value from host configuration."
|
||||
:context :config)
|
||||
|
||||
|
||||
|
||||
@@ -593,7 +593,14 @@
|
||||
;; Client-side routing
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
;; No app-specific nav update here — apps handle sx:clientRoute event.
|
||||
(define current-page-layout
|
||||
(fn ()
|
||||
;; Find the layout name of the currently displayed page by matching
|
||||
;; the browser URL against the page route table.
|
||||
(let ((pathname (url-pathname (browser-location-href)))
|
||||
(match (find-matching-route pathname _page-routes)))
|
||||
(if (nil? match) ""
|
||||
(or (get match "layout") "")))))
|
||||
|
||||
|
||||
(define swap-rendered-content
|
||||
@@ -634,9 +641,14 @@
|
||||
;; Try to render a page client-side. Returns true if successful, false otherwise.
|
||||
;; target-sel is the CSS selector for the swap target (from sx-boost value).
|
||||
;; For pure pages: renders immediately. For :data pages: fetches data then renders.
|
||||
;; Falls through to server when layout changes (needs OOB header update).
|
||||
(let ((match (find-matching-route pathname _page-routes)))
|
||||
(if (nil? match)
|
||||
(do (log-info (str "sx:route no match (" (len _page-routes) " routes) " pathname)) false)
|
||||
(let ((target-layout (or (get match "layout") ""))
|
||||
(cur-layout (current-page-layout)))
|
||||
(if (not (= target-layout cur-layout))
|
||||
(do (log-info (str "sx:route server (layout: " cur-layout " -> " target-layout ") " pathname)) false)
|
||||
(let ((content-src (get match "content"))
|
||||
(closure (or (get match "closure") {}))
|
||||
(params (get match "params"))
|
||||
@@ -715,7 +727,7 @@
|
||||
(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
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# WARNING: special-forms.sx declares forms not in eval.sx: reset, shift
|
||||
"""
|
||||
sx_ref.py -- Generated from reference SX evaluator specification.
|
||||
|
||||
@@ -777,9 +778,14 @@ PRIMITIVES["parse-int"] = lambda v, d=0: _sx_parse_int(v, d)
|
||||
PRIMITIVES["parse-datetime"] = lambda s: str(s) if s else NIL
|
||||
|
||||
def _sx_parse_int(v, default=0):
|
||||
try:
|
||||
return _b_int(v)
|
||||
except (ValueError, TypeError):
|
||||
if v is None or v is NIL:
|
||||
return default
|
||||
s = str(v).strip()
|
||||
# Match JS parseInt: extract leading integer portion
|
||||
import re as _re
|
||||
m = _re.match(r'^[+-]?\d+', s)
|
||||
if m:
|
||||
return _b_int(m.group())
|
||||
return default
|
||||
|
||||
|
||||
@@ -879,6 +885,10 @@ concat = PRIMITIVES["concat"]
|
||||
split = PRIMITIVES["split"]
|
||||
length = PRIMITIVES["len"]
|
||||
merge = PRIMITIVES["merge"]
|
||||
trim = PRIMITIVES["trim"]
|
||||
replace = PRIMITIVES["replace"]
|
||||
parse_int = PRIMITIVES["parse-int"]
|
||||
upper = PRIMITIVES["upper"]
|
||||
|
||||
|
||||
# =========================================================================
|
||||
@@ -1252,38 +1262,133 @@ compute_all_io_refs = lambda env, io_names: for_each(lambda name: (lambda val: (
|
||||
component_pure_p = lambda name, env, io_names: empty_p(transitive_io_refs(name, env, io_names))
|
||||
|
||||
|
||||
# === Transpiled from router (client-side route matching) ===
|
||||
# === Transpiled from engine (fetch/swap/trigger pure logic) ===
|
||||
|
||||
# split-path-segments
|
||||
split_path_segments = lambda path: (lambda trimmed: (lambda trimmed2: ([] if sx_truthy(empty_p(trimmed2)) else split(trimmed2, '/')))((slice(trimmed, 0, (length(trimmed) - 1)) if sx_truthy(((not sx_truthy(empty_p(trimmed))) if not sx_truthy((not sx_truthy(empty_p(trimmed)))) else ends_with_p(trimmed, '/'))) else trimmed)))((slice(path, 1) if sx_truthy(starts_with_p(path, '/')) else path))
|
||||
# ENGINE_VERBS
|
||||
ENGINE_VERBS = ['get', 'post', 'put', 'delete', 'patch']
|
||||
|
||||
# make-route-segment
|
||||
make_route_segment = lambda seg: ((lambda param_name: (lambda d: _sx_begin(_sx_dict_set(d, 'type', 'param'), _sx_dict_set(d, 'value', param_name), d))({}))(slice(seg, 1, (length(seg) - 1))) if sx_truthy((starts_with_p(seg, '<') if not sx_truthy(starts_with_p(seg, '<')) else ends_with_p(seg, '>'))) else (lambda d: _sx_begin(_sx_dict_set(d, 'type', 'literal'), _sx_dict_set(d, 'value', seg), d))({}))
|
||||
# DEFAULT_SWAP
|
||||
DEFAULT_SWAP = 'outerHTML'
|
||||
|
||||
# parse-route-pattern
|
||||
parse_route_pattern = lambda pattern: (lambda segments: map(make_route_segment, segments))(split_path_segments(pattern))
|
||||
# parse-time
|
||||
parse_time = lambda s: (0 if sx_truthy(is_nil(s)) else (parse_int(s, 0) if sx_truthy(ends_with_p(s, 'ms')) else ((parse_int(replace(s, 's', ''), 0) * 1000) if sx_truthy(ends_with_p(s, 's')) else parse_int(s, 0))))
|
||||
|
||||
# match-route-segments
|
||||
def match_route_segments(path_segs, parsed_segs):
|
||||
# parse-trigger-spec
|
||||
parse_trigger_spec = lambda spec: (NIL if sx_truthy(is_nil(spec)) else (lambda raw_parts: filter(lambda x: (not sx_truthy(is_nil(x))), map(lambda part: (lambda tokens: (NIL if sx_truthy(empty_p(tokens)) else ({'event': 'every', 'modifiers': {'interval': parse_time(nth(tokens, 1))}} if sx_truthy(((first(tokens) == 'every') if not sx_truthy((first(tokens) == 'every')) else (len(tokens) >= 2))) else (lambda mods: _sx_begin(for_each(lambda tok: (_sx_dict_set(mods, 'once', True) if sx_truthy((tok == 'once')) else (_sx_dict_set(mods, 'changed', True) if sx_truthy((tok == 'changed')) else (_sx_dict_set(mods, 'delay', parse_time(slice(tok, 6))) if sx_truthy(starts_with_p(tok, 'delay:')) else (_sx_dict_set(mods, 'from', slice(tok, 5)) if sx_truthy(starts_with_p(tok, 'from:')) else NIL)))), rest(tokens)), {'event': first(tokens), 'modifiers': mods}))({}))))(split(trim(part), ' ')), raw_parts)))(split(spec, ',')))
|
||||
|
||||
# default-trigger
|
||||
default_trigger = lambda tag_name: ([{'event': 'submit', 'modifiers': {}}] if sx_truthy((tag_name == 'FORM')) else ([{'event': 'change', 'modifiers': {}}] if sx_truthy(((tag_name == 'INPUT') if sx_truthy((tag_name == 'INPUT')) else ((tag_name == 'SELECT') if sx_truthy((tag_name == 'SELECT')) else (tag_name == 'TEXTAREA')))) else [{'event': 'click', 'modifiers': {}}]))
|
||||
|
||||
# get-verb-info
|
||||
get_verb_info = lambda el: some(lambda verb: (lambda url: ({'method': upper(verb), 'url': url} if sx_truthy(url) else NIL))(dom_get_attr(el, sx_str('sx-', verb))), ENGINE_VERBS)
|
||||
|
||||
# build-request-headers
|
||||
build_request_headers = lambda el, loaded_components, css_hash: (lambda headers: _sx_begin((lambda target_sel: (_sx_dict_set(headers, 'SX-Target', target_sel) if sx_truthy(target_sel) else NIL))(dom_get_attr(el, 'sx-target')), (_sx_dict_set(headers, 'SX-Components', join(',', loaded_components)) if sx_truthy((not sx_truthy(empty_p(loaded_components)))) else NIL), (_sx_dict_set(headers, 'SX-Css', css_hash) if sx_truthy(css_hash) else NIL), (lambda extra_h: ((lambda parsed: (for_each(lambda key: _sx_dict_set(headers, key, sx_str(get(parsed, key))), keys(parsed)) if sx_truthy(parsed) else NIL))(parse_header_value(extra_h)) if sx_truthy(extra_h) else NIL))(dom_get_attr(el, 'sx-headers')), headers))({'SX-Request': 'true', 'SX-Current-URL': browser_location_href()})
|
||||
|
||||
# process-response-headers
|
||||
process_response_headers = lambda get_header: {'redirect': get_header('SX-Redirect'), 'refresh': get_header('SX-Refresh'), 'trigger': get_header('SX-Trigger'), 'retarget': get_header('SX-Retarget'), 'reswap': get_header('SX-Reswap'), 'location': get_header('SX-Location'), 'replace-url': get_header('SX-Replace-Url'), 'css-hash': get_header('SX-Css-Hash'), 'trigger-swap': get_header('SX-Trigger-After-Swap'), 'trigger-settle': get_header('SX-Trigger-After-Settle'), 'content-type': get_header('Content-Type')}
|
||||
|
||||
# parse-swap-spec
|
||||
def parse_swap_spec(raw_swap, global_transitions_p):
|
||||
_cells = {}
|
||||
return (NIL if sx_truthy((not sx_truthy((length(path_segs) == length(parsed_segs))))) else (lambda params: _sx_begin(_sx_cell_set(_cells, 'matched', True), _sx_begin(for_each_indexed(lambda i, parsed_seg: ((lambda path_seg: (lambda seg_type: ((_sx_cell_set(_cells, 'matched', False) if sx_truthy((not sx_truthy((path_seg == get(parsed_seg, 'value'))))) else NIL) if sx_truthy((seg_type == 'literal')) else (_sx_dict_set(params, get(parsed_seg, 'value'), path_seg) if sx_truthy((seg_type == 'param')) else _sx_cell_set(_cells, 'matched', False))))(get(parsed_seg, 'type')))(nth(path_segs, i)) if sx_truthy(_cells['matched']) else NIL), parsed_segs), (params if sx_truthy(_cells['matched']) else NIL))))({}))
|
||||
parts = split((raw_swap if sx_truthy(raw_swap) else DEFAULT_SWAP), ' ')
|
||||
style = first(parts)
|
||||
_cells['use_transition'] = global_transitions_p
|
||||
for p in rest(parts):
|
||||
if sx_truthy((p == 'transition:true')):
|
||||
_cells['use_transition'] = True
|
||||
elif sx_truthy((p == 'transition:false')):
|
||||
_cells['use_transition'] = False
|
||||
return {'style': style, 'transition': _cells['use_transition']}
|
||||
|
||||
# match-route
|
||||
match_route = lambda path, pattern: (lambda path_segs: (lambda parsed_segs: match_route_segments(path_segs, parsed_segs))(parse_route_pattern(pattern)))(split_path_segments(path))
|
||||
# parse-retry-spec
|
||||
parse_retry_spec = lambda retry_attr: (NIL if sx_truthy(is_nil(retry_attr)) else (lambda parts: {'strategy': first(parts), 'start-ms': parse_int(nth(parts, 1), 1000), 'cap-ms': parse_int(nth(parts, 2), 30000)})(split(retry_attr, ':')))
|
||||
|
||||
# find-matching-route
|
||||
def find_matching_route(path, routes):
|
||||
# next-retry-ms
|
||||
next_retry_ms = lambda current_ms, cap_ms: min((current_ms * 2), cap_ms)
|
||||
|
||||
# filter-params
|
||||
filter_params = lambda params_spec, all_params: (all_params if sx_truthy(is_nil(params_spec)) else ([] if sx_truthy((params_spec == 'none')) else (all_params if sx_truthy((params_spec == '*')) else ((lambda excluded: filter(lambda p: (not sx_truthy(contains_p(excluded, first(p)))), all_params))(map(trim, split(slice(params_spec, 4), ','))) if sx_truthy(starts_with_p(params_spec, 'not ')) else (lambda allowed: filter(lambda p: contains_p(allowed, first(p)), all_params))(map(trim, split(params_spec, ',')))))))
|
||||
|
||||
# resolve-target
|
||||
resolve_target = lambda el: (lambda sel: (el if sx_truthy((is_nil(sel) if sx_truthy(is_nil(sel)) else (sel == 'this'))) else (dom_parent(el) if sx_truthy((sel == 'closest')) else dom_query(sel))))(dom_get_attr(el, 'sx-target'))
|
||||
|
||||
# apply-optimistic
|
||||
apply_optimistic = lambda el: (lambda directive: (NIL if sx_truthy(is_nil(directive)) else (lambda target: (lambda state: _sx_begin((_sx_begin(_sx_dict_set(state, 'opacity', dom_get_style(target, 'opacity')), dom_set_style(target, 'opacity', '0'), dom_set_style(target, 'pointer-events', 'none')) if sx_truthy((directive == 'remove')) else (_sx_begin(_sx_dict_set(state, 'disabled', dom_get_prop(target, 'disabled')), dom_set_prop(target, 'disabled', True)) if sx_truthy((directive == 'disable')) else ((lambda cls: _sx_begin(_sx_dict_set(state, 'add-class', cls), dom_add_class(target, cls)))(slice(directive, 10)) if sx_truthy(starts_with_p(directive, 'add-class:')) else NIL))), state))({'target': target, 'directive': directive}))((resolve_target(el) if sx_truthy(resolve_target(el)) else el))))(dom_get_attr(el, 'sx-optimistic'))
|
||||
|
||||
# revert-optimistic
|
||||
revert_optimistic = lambda state: ((lambda target: (lambda directive: (_sx_begin(dom_set_style(target, 'opacity', (get(state, 'opacity') if sx_truthy(get(state, 'opacity')) else '')), dom_set_style(target, 'pointer-events', '')) if sx_truthy((directive == 'remove')) else (dom_set_prop(target, 'disabled', (get(state, 'disabled') if sx_truthy(get(state, 'disabled')) else False)) if sx_truthy((directive == 'disable')) else (dom_remove_class(target, get(state, 'add-class')) if sx_truthy(get(state, 'add-class')) else NIL))))(get(state, 'directive')))(get(state, 'target')) if sx_truthy(state) else NIL)
|
||||
|
||||
# find-oob-swaps
|
||||
find_oob_swaps = lambda container: (lambda results: _sx_begin(for_each(lambda attr: (lambda oob_els: for_each(lambda oob: (lambda swap_type: (lambda target_id: _sx_begin(dom_remove_attr(oob, attr), (_sx_append(results, {'element': oob, 'swap-type': swap_type, 'target-id': target_id}) if sx_truthy(target_id) else NIL)))(dom_id(oob)))((dom_get_attr(oob, attr) if sx_truthy(dom_get_attr(oob, attr)) else 'outerHTML')), oob_els))(dom_query_all(container, sx_str('[', attr, ']'))), ['sx-swap-oob', 'hx-swap-oob']), results))([])
|
||||
|
||||
# morph-node
|
||||
morph_node = lambda old_node, new_node: (NIL if sx_truthy((dom_has_attr_p(old_node, 'sx-preserve') if sx_truthy(dom_has_attr_p(old_node, 'sx-preserve')) else dom_has_attr_p(old_node, 'sx-ignore'))) else (dom_replace_child(dom_parent(old_node), dom_clone(new_node), old_node) if sx_truthy(((not sx_truthy((dom_node_type(old_node) == dom_node_type(new_node)))) if sx_truthy((not sx_truthy((dom_node_type(old_node) == dom_node_type(new_node))))) else (not sx_truthy((dom_node_name(old_node) == dom_node_name(new_node)))))) else ((dom_set_text_content(old_node, dom_text_content(new_node)) if sx_truthy((not sx_truthy((dom_text_content(old_node) == dom_text_content(new_node))))) else NIL) if sx_truthy(((dom_node_type(old_node) == 3) if sx_truthy((dom_node_type(old_node) == 3)) else (dom_node_type(old_node) == 8))) else (_sx_begin(sync_attrs(old_node, new_node), (morph_children(old_node, new_node) if sx_truthy((not sx_truthy((dom_is_active_element_p(old_node) if not sx_truthy(dom_is_active_element_p(old_node)) else dom_is_input_element_p(old_node))))) else NIL)) if sx_truthy((dom_node_type(old_node) == 1)) else NIL))))
|
||||
|
||||
# sync-attrs
|
||||
sync_attrs = _sx_fn(lambda old_el, new_el: (
|
||||
for_each(lambda attr: (lambda name: (lambda val: (dom_set_attr(old_el, name, val) if sx_truthy((not sx_truthy((dom_get_attr(old_el, name) == val)))) else NIL))(nth(attr, 1)))(first(attr)), dom_attr_list(new_el)),
|
||||
for_each(lambda attr: (dom_remove_attr(old_el, first(attr)) if sx_truthy((not sx_truthy(dom_has_attr_p(new_el, first(attr))))) else NIL), dom_attr_list(old_el))
|
||||
)[-1])
|
||||
|
||||
# morph-children
|
||||
def morph_children(old_parent, new_parent):
|
||||
_cells = {}
|
||||
path_segs = split_path_segments(path)
|
||||
_cells['result'] = NIL
|
||||
for route in routes:
|
||||
if sx_truthy(is_nil(_cells['result'])):
|
||||
params = match_route_segments(path_segs, get(route, 'parsed'))
|
||||
if sx_truthy((not sx_truthy(is_nil(params)))):
|
||||
matched = merge(route, {})
|
||||
matched['params'] = params
|
||||
_cells['result'] = matched
|
||||
return _cells['result']
|
||||
old_kids = dom_child_list(old_parent)
|
||||
new_kids = dom_child_list(new_parent)
|
||||
old_by_id = reduce(lambda acc, kid: (lambda id: (_sx_begin(_sx_dict_set(acc, id, kid), acc) if sx_truthy(id) else acc))(dom_id(kid)), {}, old_kids)
|
||||
_cells['oi'] = 0
|
||||
for new_child in new_kids:
|
||||
match_id = dom_id(new_child)
|
||||
match_by_id = (dict_get(old_by_id, match_id) if sx_truthy(match_id) else NIL)
|
||||
if sx_truthy((match_by_id if not sx_truthy(match_by_id) else (not sx_truthy(is_nil(match_by_id))))):
|
||||
if sx_truthy(((_cells['oi'] < len(old_kids)) if not sx_truthy((_cells['oi'] < len(old_kids))) else (not sx_truthy((match_by_id == nth(old_kids, _cells['oi'])))))):
|
||||
dom_insert_before(old_parent, match_by_id, (nth(old_kids, _cells['oi']) if sx_truthy((_cells['oi'] < len(old_kids))) else NIL))
|
||||
morph_node(match_by_id, new_child)
|
||||
_cells['oi'] = (_cells['oi'] + 1)
|
||||
elif sx_truthy((_cells['oi'] < len(old_kids))):
|
||||
old_child = nth(old_kids, _cells['oi'])
|
||||
if sx_truthy((dom_id(old_child) if not sx_truthy(dom_id(old_child)) else (not sx_truthy(match_id)))):
|
||||
dom_insert_before(old_parent, dom_clone(new_child), old_child)
|
||||
else:
|
||||
morph_node(old_child, new_child)
|
||||
_cells['oi'] = (_cells['oi'] + 1)
|
||||
else:
|
||||
dom_append(old_parent, dom_clone(new_child))
|
||||
return for_each(lambda i: ((lambda leftover: (dom_remove_child(old_parent, leftover) if sx_truthy((dom_is_child_of_p(leftover, old_parent) if not sx_truthy(dom_is_child_of_p(leftover, old_parent)) else ((not sx_truthy(dom_has_attr_p(leftover, 'sx-preserve'))) if not sx_truthy((not sx_truthy(dom_has_attr_p(leftover, 'sx-preserve')))) else (not sx_truthy(dom_has_attr_p(leftover, 'sx-ignore')))))) else NIL))(nth(old_kids, i)) if sx_truthy((i >= _cells['oi'])) else NIL), range(_cells['oi'], len(old_kids)))
|
||||
|
||||
# swap-dom-nodes
|
||||
swap_dom_nodes = lambda target, new_nodes, strategy: _sx_case(strategy, [('innerHTML', lambda: (morph_children(target, new_nodes) if sx_truthy(dom_is_fragment_p(new_nodes)) else (lambda wrapper: _sx_begin(dom_append(wrapper, new_nodes), morph_children(target, wrapper)))(dom_create_element('div', NIL)))), ('outerHTML', lambda: (lambda parent: _sx_begin(((lambda fc: (_sx_begin(morph_node(target, fc), (lambda sib: insert_remaining_siblings(parent, target, sib))(dom_next_sibling(fc))) if sx_truthy(fc) else dom_remove_child(parent, target)))(dom_first_child(new_nodes)) if sx_truthy(dom_is_fragment_p(new_nodes)) else morph_node(target, new_nodes)), parent))(dom_parent(target))), ('afterend', lambda: dom_insert_after(target, new_nodes)), ('beforeend', lambda: dom_append(target, new_nodes)), ('afterbegin', lambda: dom_prepend(target, new_nodes)), ('beforebegin', lambda: dom_insert_before(dom_parent(target), new_nodes, target)), ('delete', lambda: dom_remove_child(dom_parent(target), target)), ('none', lambda: NIL), (None, lambda: (morph_children(target, new_nodes) if sx_truthy(dom_is_fragment_p(new_nodes)) else (lambda wrapper: _sx_begin(dom_append(wrapper, new_nodes), morph_children(target, wrapper)))(dom_create_element('div', NIL))))])
|
||||
|
||||
# insert-remaining-siblings
|
||||
insert_remaining_siblings = lambda parent, ref_node, sib: ((lambda next: _sx_begin(dom_insert_after(ref_node, sib), insert_remaining_siblings(parent, sib, next)))(dom_next_sibling(sib)) if sx_truthy(sib) else NIL)
|
||||
|
||||
# swap-html-string
|
||||
swap_html_string = lambda target, html, strategy: _sx_case(strategy, [('innerHTML', lambda: dom_set_inner_html(target, html)), ('outerHTML', lambda: (lambda parent: _sx_begin(dom_insert_adjacent_html(target, 'afterend', html), dom_remove_child(parent, target), parent))(dom_parent(target))), ('afterend', lambda: dom_insert_adjacent_html(target, 'afterend', html)), ('beforeend', lambda: dom_insert_adjacent_html(target, 'beforeend', html)), ('afterbegin', lambda: dom_insert_adjacent_html(target, 'afterbegin', html)), ('beforebegin', lambda: dom_insert_adjacent_html(target, 'beforebegin', html)), ('delete', lambda: dom_remove_child(dom_parent(target), target)), ('none', lambda: NIL), (None, lambda: dom_set_inner_html(target, html))])
|
||||
|
||||
# handle-history
|
||||
handle_history = lambda el, url, resp_headers: (lambda push_url: (lambda replace_url: (lambda hdr_replace: (browser_replace_state(hdr_replace) if sx_truthy(hdr_replace) else (browser_push_state((url if sx_truthy((push_url == 'true')) else push_url)) if sx_truthy((push_url if not sx_truthy(push_url) else (not sx_truthy((push_url == 'false'))))) else (browser_replace_state((url if sx_truthy((replace_url == 'true')) else replace_url)) if sx_truthy((replace_url if not sx_truthy(replace_url) else (not sx_truthy((replace_url == 'false'))))) else NIL))))(get(resp_headers, 'replace-url')))(dom_get_attr(el, 'sx-replace-url')))(dom_get_attr(el, 'sx-push-url'))
|
||||
|
||||
# PRELOAD_TTL
|
||||
PRELOAD_TTL = 30000
|
||||
|
||||
# preload-cache-get
|
||||
preload_cache_get = lambda cache, url: (lambda entry: (NIL if sx_truthy(is_nil(entry)) else (_sx_begin(dict_delete(cache, url), NIL) if sx_truthy(((now_ms() - get(entry, 'timestamp')) > PRELOAD_TTL)) else _sx_begin(dict_delete(cache, url), entry))))(dict_get(cache, url))
|
||||
|
||||
# preload-cache-set
|
||||
preload_cache_set = lambda cache, url, text, content_type: _sx_dict_set(cache, url, {'text': text, 'content-type': content_type, 'timestamp': now_ms()})
|
||||
|
||||
# classify-trigger
|
||||
classify_trigger = lambda trigger: (lambda event: ('poll' if sx_truthy((event == 'every')) else ('intersect' if sx_truthy((event == 'intersect')) else ('load' if sx_truthy((event == 'load')) else ('revealed' if sx_truthy((event == 'revealed')) else 'event')))))(get(trigger, 'event'))
|
||||
|
||||
# should-boost-link?
|
||||
should_boost_link_p = lambda link: (lambda href: (href if not sx_truthy(href) else ((not sx_truthy(starts_with_p(href, '#'))) if not sx_truthy((not sx_truthy(starts_with_p(href, '#')))) else ((not sx_truthy(starts_with_p(href, 'javascript:'))) if not sx_truthy((not sx_truthy(starts_with_p(href, 'javascript:')))) else ((not sx_truthy(starts_with_p(href, 'mailto:'))) if not sx_truthy((not sx_truthy(starts_with_p(href, 'mailto:')))) else (browser_same_origin_p(href) if not sx_truthy(browser_same_origin_p(href)) else ((not sx_truthy(dom_has_attr_p(link, 'sx-get'))) if not sx_truthy((not sx_truthy(dom_has_attr_p(link, 'sx-get')))) else ((not sx_truthy(dom_has_attr_p(link, 'sx-post'))) if not sx_truthy((not sx_truthy(dom_has_attr_p(link, 'sx-post')))) else (not sx_truthy(dom_has_attr_p(link, 'sx-disable')))))))))))(dom_get_attr(link, 'href'))
|
||||
|
||||
# should-boost-form?
|
||||
should_boost_form_p = lambda form: ((not sx_truthy(dom_has_attr_p(form, 'sx-get'))) if not sx_truthy((not sx_truthy(dom_has_attr_p(form, 'sx-get')))) else ((not sx_truthy(dom_has_attr_p(form, 'sx-post'))) if not sx_truthy((not sx_truthy(dom_has_attr_p(form, 'sx-post')))) else (not sx_truthy(dom_has_attr_p(form, 'sx-disable')))))
|
||||
|
||||
# parse-sse-swap
|
||||
parse_sse_swap = lambda el: (dom_get_attr(el, 'sx-sse-swap') if sx_truthy(dom_get_attr(el, 'sx-sse-swap')) else 'message')
|
||||
|
||||
|
||||
# =========================================================================
|
||||
@@ -1318,64 +1423,6 @@ def _wrap_aser_outputs():
|
||||
aser_fragment = _aser_fragment_wrapped
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Extension: delimited continuations (shift/reset)
|
||||
# =========================================================================
|
||||
|
||||
_RESET_RESUME = [] # stack of resume values; empty = not resuming
|
||||
|
||||
_SPECIAL_FORM_NAMES = _SPECIAL_FORM_NAMES | frozenset(["reset", "shift"])
|
||||
|
||||
def sf_reset(args, env):
|
||||
"""(reset body) -- establish a continuation delimiter."""
|
||||
body = first(args)
|
||||
try:
|
||||
return trampoline(eval_expr(body, env))
|
||||
except _ShiftSignal as sig:
|
||||
def cont_fn(value=NIL):
|
||||
_RESET_RESUME.append(value)
|
||||
try:
|
||||
return trampoline(eval_expr(body, env))
|
||||
finally:
|
||||
_RESET_RESUME.pop()
|
||||
k = Continuation(cont_fn)
|
||||
sig_env = dict(sig.env)
|
||||
sig_env[sig.k_name] = k
|
||||
return trampoline(eval_expr(sig.body, sig_env))
|
||||
|
||||
def sf_shift(args, env):
|
||||
"""(shift k body) -- capture continuation to nearest reset."""
|
||||
if _RESET_RESUME:
|
||||
return _RESET_RESUME[-1]
|
||||
k_name = symbol_name(first(args))
|
||||
body = nth(args, 1)
|
||||
raise _ShiftSignal(k_name, body, env)
|
||||
|
||||
# Wrap eval_list to inject shift/reset dispatch
|
||||
_base_eval_list = eval_list
|
||||
def _eval_list_with_continuations(expr, env):
|
||||
head = first(expr)
|
||||
if type_of(head) == "symbol":
|
||||
name = symbol_name(head)
|
||||
args = rest(expr)
|
||||
if name == "reset":
|
||||
return sf_reset(args, env)
|
||||
if name == "shift":
|
||||
return sf_shift(args, env)
|
||||
return _base_eval_list(expr, env)
|
||||
eval_list = _eval_list_with_continuations
|
||||
|
||||
# Inject into aser_special
|
||||
_base_aser_special = aser_special
|
||||
def _aser_special_with_continuations(name, expr, env):
|
||||
if name == "reset":
|
||||
return sf_reset(expr[1:], env)
|
||||
if name == "shift":
|
||||
return sf_shift(expr[1:], env)
|
||||
return _base_aser_special(name, expr, env)
|
||||
aser_special = _aser_special_with_continuations
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Public API
|
||||
# =========================================================================
|
||||
|
||||
225
shared/sx/ref/test-deps.sx
Normal file
225
shared/sx/ref/test-deps.sx
Normal file
@@ -0,0 +1,225 @@
|
||||
;; ==========================================================================
|
||||
;; test-deps.sx — Tests for component dependency analysis (deps.sx)
|
||||
;;
|
||||
;; Requires: test-framework.sx loaded first.
|
||||
;; Platform functions: scan-refs, transitive-deps, components-needed,
|
||||
;; component-pure?, scan-io-refs, transitive-io-refs,
|
||||
;; scan-components-from-source, test-env
|
||||
;; (loaded from bootstrapped output by test runners)
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; Test component definitions — these exist in the test env for dep analysis
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~dep-leaf ()
|
||||
(span "leaf"))
|
||||
|
||||
(defcomp ~dep-branch ()
|
||||
(div (~dep-leaf)))
|
||||
|
||||
(defcomp ~dep-trunk ()
|
||||
(div (~dep-branch) (~dep-leaf)))
|
||||
|
||||
(defcomp ~dep-conditional (&key show?)
|
||||
(if show?
|
||||
(~dep-leaf)
|
||||
(~dep-branch)))
|
||||
|
||||
(defcomp ~dep-nested-cond (&key mode)
|
||||
(cond
|
||||
(= mode "a") (~dep-leaf)
|
||||
(= mode "b") (~dep-branch)
|
||||
:else (~dep-trunk)))
|
||||
|
||||
(defcomp ~dep-island ()
|
||||
(div "no deps"))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 1. scan-refs — finds component references in AST nodes
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "scan-refs"
|
||||
|
||||
(deftest "empty for string literal"
|
||||
(assert-equal (list) (scan-refs "hello")))
|
||||
|
||||
(deftest "empty for number"
|
||||
(assert-equal (list) (scan-refs 42)))
|
||||
|
||||
(deftest "finds component symbol"
|
||||
(let ((refs (scan-refs (quote (~dep-leaf)))))
|
||||
(assert-contains "~dep-leaf" refs)))
|
||||
|
||||
(deftest "finds in nested list"
|
||||
(let ((refs (scan-refs (quote (div (span (~dep-leaf)))))))
|
||||
(assert-contains "~dep-leaf" refs)))
|
||||
|
||||
(deftest "finds multiple refs"
|
||||
(let ((refs (scan-refs (quote (div (~dep-leaf) (~dep-branch))))))
|
||||
(assert-contains "~dep-leaf" refs)
|
||||
(assert-contains "~dep-branch" refs)))
|
||||
|
||||
(deftest "deduplicates"
|
||||
(let ((refs (scan-refs (quote (div (~dep-leaf) (~dep-leaf))))))
|
||||
(assert-equal 1 (len refs))))
|
||||
|
||||
(deftest "walks if branches"
|
||||
(let ((refs (scan-refs (quote (if true (~dep-leaf) (~dep-branch))))))
|
||||
(assert-contains "~dep-leaf" refs)
|
||||
(assert-contains "~dep-branch" refs)))
|
||||
|
||||
(deftest "walks cond branches"
|
||||
(let ((refs (scan-refs (quote (cond (= x 1) (~dep-leaf) :else (~dep-trunk))))))
|
||||
(assert-contains "~dep-leaf" refs)
|
||||
(assert-contains "~dep-trunk" refs)))
|
||||
|
||||
(deftest "ignores non-component symbols"
|
||||
(let ((refs (scan-refs (quote (div class "foo")))))
|
||||
(assert-equal 0 (len refs)))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 2. scan-components-from-source — regex-based source string scanning
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "scan-components-from-source"
|
||||
|
||||
(deftest "finds single component"
|
||||
(let ((refs (scan-components-from-source "(~dep-leaf)")))
|
||||
(assert-contains "~dep-leaf" refs)))
|
||||
|
||||
(deftest "finds multiple components"
|
||||
(let ((refs (scan-components-from-source "(div (~dep-leaf) (~dep-branch))")))
|
||||
(assert-contains "~dep-leaf" refs)
|
||||
(assert-contains "~dep-branch" refs)))
|
||||
|
||||
(deftest "no false positives on plain text"
|
||||
(let ((refs (scan-components-from-source "(div \"hello world\")")))
|
||||
(assert-equal 0 (len refs))))
|
||||
|
||||
(deftest "handles hyphenated names"
|
||||
(let ((refs (scan-components-from-source "(~my-component :key val)")))
|
||||
(assert-contains "~my-component" refs))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3. transitive-deps — transitive dependency closure
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "transitive-deps"
|
||||
|
||||
(deftest "leaf has no deps"
|
||||
(let ((deps (transitive-deps "~dep-leaf" (test-env))))
|
||||
(assert-equal 0 (len deps))))
|
||||
|
||||
(deftest "direct dependency"
|
||||
(let ((deps (transitive-deps "~dep-branch" (test-env))))
|
||||
(assert-contains "~dep-leaf" deps)))
|
||||
|
||||
(deftest "transitive closure"
|
||||
(let ((deps (transitive-deps "~dep-trunk" (test-env))))
|
||||
(assert-contains "~dep-branch" deps)
|
||||
(assert-contains "~dep-leaf" deps)))
|
||||
|
||||
(deftest "excludes self"
|
||||
(let ((deps (transitive-deps "~dep-trunk" (test-env))))
|
||||
(assert-false (contains? deps "~dep-trunk"))))
|
||||
|
||||
(deftest "walks conditional branches"
|
||||
(let ((deps (transitive-deps "~dep-conditional" (test-env))))
|
||||
(assert-contains "~dep-leaf" deps)
|
||||
(assert-contains "~dep-branch" deps)))
|
||||
|
||||
(deftest "walks all cond branches"
|
||||
(let ((deps (transitive-deps "~dep-nested-cond" (test-env))))
|
||||
(assert-contains "~dep-leaf" deps)
|
||||
(assert-contains "~dep-branch" deps)
|
||||
(assert-contains "~dep-trunk" deps)))
|
||||
|
||||
(deftest "island has no deps"
|
||||
(let ((deps (transitive-deps "~dep-island" (test-env))))
|
||||
(assert-equal 0 (len deps))))
|
||||
|
||||
(deftest "accepts name without tilde"
|
||||
(let ((deps (transitive-deps "dep-branch" (test-env))))
|
||||
(assert-contains "~dep-leaf" deps))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 4. components-needed — page bundle computation
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "components-needed"
|
||||
|
||||
(deftest "finds direct and transitive"
|
||||
(let ((needed (components-needed "(~dep-trunk)" (test-env))))
|
||||
(assert-contains "~dep-trunk" needed)
|
||||
(assert-contains "~dep-branch" needed)
|
||||
(assert-contains "~dep-leaf" needed)))
|
||||
|
||||
(deftest "deduplicates"
|
||||
(let ((needed (components-needed "(div (~dep-leaf) (~dep-leaf))" (test-env))))
|
||||
;; ~dep-leaf should appear only once
|
||||
(assert-true (contains? needed "~dep-leaf"))))
|
||||
|
||||
(deftest "handles leaf page"
|
||||
(let ((needed (components-needed "(~dep-island)" (test-env))))
|
||||
(assert-contains "~dep-island" needed)
|
||||
(assert-equal 1 (len needed))))
|
||||
|
||||
(deftest "handles multiple top-level components"
|
||||
(let ((needed (components-needed "(div (~dep-leaf) (~dep-island))" (test-env))))
|
||||
(assert-contains "~dep-leaf" needed)
|
||||
(assert-contains "~dep-island" needed))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 5. IO detection — scan-io-refs, component-pure?
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
;; Define components that reference "io" functions for testing
|
||||
(defcomp ~dep-pure ()
|
||||
(div (~dep-leaf) "static"))
|
||||
|
||||
(defcomp ~dep-io ()
|
||||
(div (fetch-data "/api")))
|
||||
|
||||
(defcomp ~dep-io-indirect ()
|
||||
(div (~dep-io)))
|
||||
|
||||
(defsuite "scan-io-refs"
|
||||
|
||||
(deftest "no IO in pure AST"
|
||||
(let ((refs (scan-io-refs (quote (div "hello" (span "world"))) (list "fetch-data"))))
|
||||
(assert-equal 0 (len refs))))
|
||||
|
||||
(deftest "finds IO reference"
|
||||
(let ((refs (scan-io-refs (quote (div (fetch-data "/api"))) (list "fetch-data"))))
|
||||
(assert-contains "fetch-data" refs)))
|
||||
|
||||
(deftest "multiple IO refs"
|
||||
(let ((refs (scan-io-refs (quote (div (fetch-data "/a") (query-db "x"))) (list "fetch-data" "query-db"))))
|
||||
(assert-contains "fetch-data" refs)
|
||||
(assert-contains "query-db" refs)))
|
||||
|
||||
(deftest "ignores non-IO symbols"
|
||||
(let ((refs (scan-io-refs (quote (div (map str items))) (list "fetch-data"))))
|
||||
(assert-equal 0 (len refs)))))
|
||||
|
||||
|
||||
(defsuite "component-pure?"
|
||||
|
||||
(deftest "pure component is pure"
|
||||
(assert-true (component-pure? "~dep-pure" (test-env) (list "fetch-data"))))
|
||||
|
||||
(deftest "IO component is not pure"
|
||||
(assert-false (component-pure? "~dep-io" (test-env) (list "fetch-data"))))
|
||||
|
||||
(deftest "indirect IO is not pure"
|
||||
(assert-false (component-pure? "~dep-io-indirect" (test-env) (list "fetch-data"))))
|
||||
|
||||
(deftest "leaf component is pure"
|
||||
(assert-true (component-pure? "~dep-leaf" (test-env) (list "fetch-data")))))
|
||||
212
shared/sx/ref/test-engine.sx
Normal file
212
shared/sx/ref/test-engine.sx
Normal file
@@ -0,0 +1,212 @@
|
||||
;; ==========================================================================
|
||||
;; test-engine.sx — Tests for SxEngine pure logic (engine.sx)
|
||||
;;
|
||||
;; Requires: test-framework.sx loaded first.
|
||||
;; Platform functions: parse-time, parse-trigger-spec, default-trigger,
|
||||
;; parse-swap-spec, parse-retry-spec, next-retry-ms, filter-params
|
||||
;; (loaded from bootstrapped output by test runners)
|
||||
;; ==========================================================================
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 1. parse-time — time string parsing
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "parse-time"
|
||||
|
||||
(deftest "seconds to ms"
|
||||
(assert-equal 2000 (parse-time "2s")))
|
||||
|
||||
(deftest "milliseconds"
|
||||
(assert-equal 500 (parse-time "500ms")))
|
||||
|
||||
(deftest "nil returns 0"
|
||||
(assert-equal 0 (parse-time nil)))
|
||||
|
||||
(deftest "plain number string"
|
||||
(assert-equal 100 (parse-time "100")))
|
||||
|
||||
(deftest "one second"
|
||||
(assert-equal 1000 (parse-time "1s")))
|
||||
|
||||
(deftest "large seconds"
|
||||
(assert-equal 30000 (parse-time "30s"))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 2. parse-trigger-spec — trigger attribute parsing
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "parse-trigger-spec"
|
||||
|
||||
(deftest "nil returns nil"
|
||||
(assert-nil (parse-trigger-spec nil)))
|
||||
|
||||
(deftest "single event"
|
||||
(let ((triggers (parse-trigger-spec "click")))
|
||||
(assert-equal 1 (len triggers))
|
||||
(assert-equal "click" (get (first triggers) "event"))))
|
||||
|
||||
(deftest "event with once modifier"
|
||||
(let ((triggers (parse-trigger-spec "click once")))
|
||||
(assert-equal 1 (len triggers))
|
||||
(assert-equal "click" (get (first triggers) "event"))
|
||||
(assert-true (get (get (first triggers) "modifiers") "once"))))
|
||||
|
||||
(deftest "event with delay modifier"
|
||||
(let ((triggers (parse-trigger-spec "click delay:500ms")))
|
||||
(assert-equal 1 (len triggers))
|
||||
(assert-equal 500 (get (get (first triggers) "modifiers") "delay"))))
|
||||
|
||||
(deftest "multiple triggers comma-separated"
|
||||
(let ((triggers (parse-trigger-spec "click,change")))
|
||||
(assert-equal 2 (len triggers))
|
||||
(assert-equal "click" (get (first triggers) "event"))
|
||||
(assert-equal "change" (get (nth triggers 1) "event"))))
|
||||
|
||||
(deftest "polling trigger"
|
||||
(let ((triggers (parse-trigger-spec "every 3s")))
|
||||
(assert-equal 1 (len triggers))
|
||||
(assert-equal "every" (get (first triggers) "event"))
|
||||
(assert-equal 3000 (get (get (first triggers) "modifiers") "interval"))))
|
||||
|
||||
(deftest "event with from modifier"
|
||||
(let ((triggers (parse-trigger-spec "click from:body")))
|
||||
(assert-equal "body" (get (get (first triggers) "modifiers") "from"))))
|
||||
|
||||
(deftest "event with changed modifier"
|
||||
(let ((triggers (parse-trigger-spec "keyup changed")))
|
||||
(assert-equal "keyup" (get (first triggers) "event"))
|
||||
(assert-true (get (get (first triggers) "modifiers") "changed")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 3. default-trigger — default trigger by element tag
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "default-trigger"
|
||||
|
||||
(deftest "form submits"
|
||||
(let ((triggers (default-trigger "FORM")))
|
||||
(assert-equal "submit" (get (first triggers) "event"))))
|
||||
|
||||
(deftest "input changes"
|
||||
(let ((triggers (default-trigger "INPUT")))
|
||||
(assert-equal "change" (get (first triggers) "event"))))
|
||||
|
||||
(deftest "select changes"
|
||||
(let ((triggers (default-trigger "SELECT")))
|
||||
(assert-equal "change" (get (first triggers) "event"))))
|
||||
|
||||
(deftest "textarea changes"
|
||||
(let ((triggers (default-trigger "TEXTAREA")))
|
||||
(assert-equal "change" (get (first triggers) "event"))))
|
||||
|
||||
(deftest "div clicks"
|
||||
(let ((triggers (default-trigger "DIV")))
|
||||
(assert-equal "click" (get (first triggers) "event"))))
|
||||
|
||||
(deftest "button clicks"
|
||||
(let ((triggers (default-trigger "BUTTON")))
|
||||
(assert-equal "click" (get (first triggers) "event")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 4. parse-swap-spec — swap specification parsing
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "parse-swap-spec"
|
||||
|
||||
(deftest "default swap"
|
||||
(let ((spec (parse-swap-spec nil false)))
|
||||
(assert-equal "outerHTML" (get spec "style"))
|
||||
(assert-false (get spec "transition"))))
|
||||
|
||||
(deftest "innerHTML"
|
||||
(let ((spec (parse-swap-spec "innerHTML" false)))
|
||||
(assert-equal "innerHTML" (get spec "style"))))
|
||||
|
||||
(deftest "with transition true"
|
||||
(let ((spec (parse-swap-spec "innerHTML transition:true" false)))
|
||||
(assert-equal "innerHTML" (get spec "style"))
|
||||
(assert-true (get spec "transition"))))
|
||||
|
||||
(deftest "transition false overrides global"
|
||||
(let ((spec (parse-swap-spec "outerHTML transition:false" true)))
|
||||
(assert-equal "outerHTML" (get spec "style"))
|
||||
(assert-false (get spec "transition"))))
|
||||
|
||||
(deftest "global transition when not overridden"
|
||||
(let ((spec (parse-swap-spec "innerHTML" true)))
|
||||
(assert-equal "innerHTML" (get spec "style"))
|
||||
(assert-true (get spec "transition")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 5. parse-retry-spec — retry specification parsing
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "parse-retry-spec"
|
||||
|
||||
(deftest "nil returns nil"
|
||||
(assert-nil (parse-retry-spec nil)))
|
||||
|
||||
(deftest "exponential backoff"
|
||||
(let ((spec (parse-retry-spec "exponential:1000:30000")))
|
||||
(assert-equal "exponential" (get spec "strategy"))
|
||||
(assert-equal 1000 (get spec "start-ms"))
|
||||
(assert-equal 30000 (get spec "cap-ms"))))
|
||||
|
||||
(deftest "linear strategy"
|
||||
(let ((spec (parse-retry-spec "linear:2000:60000")))
|
||||
(assert-equal "linear" (get spec "strategy"))
|
||||
(assert-equal 2000 (get spec "start-ms"))
|
||||
(assert-equal 60000 (get spec "cap-ms")))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 6. next-retry-ms — exponential backoff calculation
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "next-retry-ms"
|
||||
|
||||
(deftest "doubles current"
|
||||
(assert-equal 2000 (next-retry-ms 1000 30000)))
|
||||
|
||||
(deftest "caps at maximum"
|
||||
(assert-equal 30000 (next-retry-ms 20000 30000)))
|
||||
|
||||
(deftest "exact cap"
|
||||
(assert-equal 30000 (next-retry-ms 15000 30000)))
|
||||
|
||||
(deftest "small initial"
|
||||
(assert-equal 200 (next-retry-ms 100 30000))))
|
||||
|
||||
|
||||
;; --------------------------------------------------------------------------
|
||||
;; 7. filter-params — form parameter filtering
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
(defsuite "filter-params"
|
||||
|
||||
(deftest "nil passes all through"
|
||||
(let ((params (list (list "a" "1") (list "b" "2"))))
|
||||
(assert-equal 2 (len (filter-params nil params)))))
|
||||
|
||||
(deftest "none returns empty"
|
||||
(let ((params (list (list "a" "1") (list "b" "2"))))
|
||||
(assert-equal 0 (len (filter-params "none" params)))))
|
||||
|
||||
(deftest "star passes all"
|
||||
(let ((params (list (list "a" "1") (list "b" "2"))))
|
||||
(assert-equal 2 (len (filter-params "*" params)))))
|
||||
|
||||
(deftest "whitelist"
|
||||
(let ((params (list (list "name" "Jo") (list "age" "30") (list "secret" "x"))))
|
||||
(let ((filtered (filter-params "name,age" params)))
|
||||
(assert-equal 2 (len filtered)))))
|
||||
|
||||
(deftest "blacklist with not"
|
||||
(let ((params (list (list "name" "Jo") (list "csrf" "tok") (list "age" "30"))))
|
||||
(let ((filtered (filter-params "not csrf" params)))
|
||||
(assert-equal 2 (len filtered))))))
|
||||
@@ -15,6 +15,20 @@
|
||||
(body :class "bg-stone-50 text-stone-900"
|
||||
children))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Suspense — streaming placeholder that renders fallback until resolved.
|
||||
;;
|
||||
;; Server-side: rendered in the initial streaming chunk with a fallback.
|
||||
;; Client-side: replaced when the server streams a resolution chunk via
|
||||
;; <script>__sxResolve("id", "(resolved sx ...)")</script>
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~suspense (&key id fallback &rest children)
|
||||
(div :id (str "sx-suspense-" id)
|
||||
:data-suspense id
|
||||
:style "display:contents"
|
||||
(if children children fallback)))
|
||||
|
||||
(defcomp ~error-page (&key title message image asset-url)
|
||||
(~base-shell :title title :asset-url asset-url
|
||||
(div :class "text-center p-8 max-w-lg mx-auto"
|
||||
|
||||
@@ -159,6 +159,8 @@ var SPECS = {
|
||||
"parser": { file: "test-parser.sx", needs: ["sx-parse"] },
|
||||
"router": { file: "test-router.sx", needs: [] },
|
||||
"render": { file: "test-render.sx", needs: ["render-html"] },
|
||||
"deps": { file: "test-deps.sx", needs: [] },
|
||||
"engine": { file: "test-engine.sx", needs: [] },
|
||||
};
|
||||
|
||||
function evalFile(filename) {
|
||||
@@ -215,9 +217,6 @@ if (args[0] === "--legacy") {
|
||||
|
||||
// Load prerequisite spec modules
|
||||
if (specName === "router") {
|
||||
// Use bootstrapped router functions from sx-browser.js.
|
||||
// The bare evaluator can't run router.sx faithfully because set!
|
||||
// inside lambda closures doesn't propagate (dict copies, not cells).
|
||||
if (Sx.splitPathSegments) {
|
||||
env["split-path-segments"] = Sx.splitPathSegments;
|
||||
env["parse-route-pattern"] = Sx.parseRoutePattern;
|
||||
@@ -230,6 +229,35 @@ if (args[0] === "--legacy") {
|
||||
}
|
||||
}
|
||||
|
||||
if (specName === "deps") {
|
||||
if (Sx.scanRefs) {
|
||||
env["scan-refs"] = Sx.scanRefs;
|
||||
env["scan-components-from-source"] = Sx.scanComponentsFromSource;
|
||||
env["transitive-deps"] = Sx.transitiveDeps;
|
||||
env["compute-all-deps"] = Sx.computeAllDeps;
|
||||
env["components-needed"] = Sx.componentsNeeded;
|
||||
env["page-component-bundle"] = Sx.pageComponentBundle;
|
||||
env["page-css-classes"] = Sx.pageCssClasses;
|
||||
env["scan-io-refs"] = Sx.scanIoRefs;
|
||||
env["transitive-io-refs"] = Sx.transitiveIoRefs;
|
||||
env["compute-all-io-refs"] = Sx.computeAllIoRefs;
|
||||
env["component-pure?"] = Sx.componentPure_p;
|
||||
env["test-env"] = function() { return env; };
|
||||
}
|
||||
}
|
||||
|
||||
if (specName === "engine") {
|
||||
if (Sx.parseTime) {
|
||||
env["parse-time"] = Sx.parseTime;
|
||||
env["parse-trigger-spec"] = Sx.parseTriggerSpec;
|
||||
env["default-trigger"] = Sx.defaultTrigger;
|
||||
env["parse-swap-spec"] = Sx.parseSwapSpec;
|
||||
env["parse-retry-spec"] = Sx.parseRetrySpec;
|
||||
env["next-retry-ms"] = function(cur, cap) { return Math.min(cur * 2, cap); };
|
||||
env["filter-params"] = Sx.filterParams;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("# --- " + specName + " ---");
|
||||
evalFile(spec.file);
|
||||
}
|
||||
|
||||
@@ -141,6 +141,8 @@ SPECS = {
|
||||
"parser": {"file": "test-parser.sx", "needs": ["sx-parse"]},
|
||||
"router": {"file": "test-router.sx", "needs": []},
|
||||
"render": {"file": "test-render.sx", "needs": ["render-html"]},
|
||||
"deps": {"file": "test-deps.sx", "needs": []},
|
||||
"engine": {"file": "test-engine.sx", "needs": []},
|
||||
}
|
||||
|
||||
REF_DIR = os.path.join(_HERE, "..", "ref")
|
||||
@@ -269,6 +271,62 @@ def _load_router_from_bootstrap(env):
|
||||
eval_file("router.sx", env)
|
||||
|
||||
|
||||
def _load_deps_from_bootstrap(env):
|
||||
"""Load deps functions from the bootstrapped sx_ref.py."""
|
||||
try:
|
||||
from shared.sx.ref.sx_ref import (
|
||||
scan_refs,
|
||||
scan_components_from_source,
|
||||
transitive_deps,
|
||||
compute_all_deps,
|
||||
components_needed,
|
||||
page_component_bundle,
|
||||
page_css_classes,
|
||||
scan_io_refs,
|
||||
transitive_io_refs,
|
||||
compute_all_io_refs,
|
||||
component_pure_p,
|
||||
)
|
||||
env["scan-refs"] = scan_refs
|
||||
env["scan-components-from-source"] = scan_components_from_source
|
||||
env["transitive-deps"] = transitive_deps
|
||||
env["compute-all-deps"] = compute_all_deps
|
||||
env["components-needed"] = components_needed
|
||||
env["page-component-bundle"] = page_component_bundle
|
||||
env["page-css-classes"] = page_css_classes
|
||||
env["scan-io-refs"] = scan_io_refs
|
||||
env["transitive-io-refs"] = transitive_io_refs
|
||||
env["compute-all-io-refs"] = compute_all_io_refs
|
||||
env["component-pure?"] = component_pure_p
|
||||
env["test-env"] = lambda: env
|
||||
except ImportError:
|
||||
eval_file("deps.sx", env)
|
||||
env["test-env"] = lambda: env
|
||||
|
||||
|
||||
def _load_engine_from_bootstrap(env):
|
||||
"""Load engine pure functions from the bootstrapped sx_ref.py."""
|
||||
try:
|
||||
from shared.sx.ref.sx_ref import (
|
||||
parse_time,
|
||||
parse_trigger_spec,
|
||||
default_trigger,
|
||||
parse_swap_spec,
|
||||
parse_retry_spec,
|
||||
next_retry_ms,
|
||||
filter_params,
|
||||
)
|
||||
env["parse-time"] = parse_time
|
||||
env["parse-trigger-spec"] = parse_trigger_spec
|
||||
env["default-trigger"] = default_trigger
|
||||
env["parse-swap-spec"] = parse_swap_spec
|
||||
env["parse-retry-spec"] = parse_retry_spec
|
||||
env["next-retry-ms"] = next_retry_ms
|
||||
env["filter-params"] = filter_params
|
||||
except ImportError:
|
||||
eval_file("engine.sx", env)
|
||||
|
||||
|
||||
def main():
|
||||
global passed, failed, test_num
|
||||
|
||||
@@ -306,6 +364,10 @@ def main():
|
||||
# Load prerequisite spec modules
|
||||
if spec_name == "router":
|
||||
_load_router_from_bootstrap(env)
|
||||
if spec_name == "deps":
|
||||
_load_deps_from_bootstrap(env)
|
||||
if spec_name == "engine":
|
||||
_load_engine_from_bootstrap(env)
|
||||
|
||||
print(f"# --- {spec_name} ---")
|
||||
eval_file(spec["file"], env)
|
||||
|
||||
@@ -241,6 +241,8 @@ class PageDef:
|
||||
filter_expr: Any
|
||||
aside_expr: Any
|
||||
menu_expr: Any
|
||||
stream: bool = False # enable streaming response
|
||||
fallback_expr: Any = None # fallback content while streaming
|
||||
closure: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __repr__(self):
|
||||
|
||||
@@ -69,3 +69,8 @@
|
||||
:params (spec-name)
|
||||
:returns "dict"
|
||||
:service "sx")
|
||||
|
||||
(define-page-helper "streaming-demo-data"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:service "sx")
|
||||
|
||||
@@ -115,6 +115,8 @@
|
||||
(dict :label "Parser" :href "/testing/parser")
|
||||
(dict :label "Router" :href "/testing/router")
|
||||
(dict :label "Renderer" :href "/testing/render")
|
||||
(dict :label "Dependencies" :href "/testing/deps")
|
||||
(dict :label "Engine" :href "/testing/engine")
|
||||
(dict :label "Runners" :href "/testing/runners")))
|
||||
|
||||
(define isomorphism-nav-items (list
|
||||
@@ -122,7 +124,8 @@
|
||||
(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 "Async IO" :href "/isomorphism/async-io")))
|
||||
(dict :label "Async IO" :href "/isomorphism/async-io")
|
||||
(dict :label "Streaming" :href "/isomorphism/streaming")))
|
||||
|
||||
(define plans-nav-items (list
|
||||
(dict :label "Status" :href "/plans/status"
|
||||
|
||||
109
sx/sx/plans.sx
109
sx/sx/plans.sx
@@ -1386,12 +1386,12 @@
|
||||
(p :class "text-sm text-stone-600" "OAuth-based sharing to Facebook, Instagram, Threads, Twitter/X, LinkedIn, and Mastodon via the account service. No models, blueprints, or platform clients created.")
|
||||
(p :class "text-sm text-stone-500 mt-1" "Remaining: SocialConnection model, social_crypto.py, platform OAuth clients (6), account/bp/social/ blueprint, share button fragment."))
|
||||
|
||||
(div :class "rounded border border-stone-200 bg-stone-50 p-4"
|
||||
(div :class "rounded border border-green-200 bg-green-50 p-4"
|
||||
(div :class "flex items-center gap-2 mb-1"
|
||||
(span :class "inline-block px-2 py-0.5 rounded text-xs font-bold bg-stone-500 text-white uppercase" "Not Started")
|
||||
(span :class "inline-block px-2 py-0.5 rounded text-xs font-bold bg-green-600 text-white uppercase" "Complete")
|
||||
(a :href "/isomorphism/" :class "font-semibold text-stone-800 underline" "Isomorphic Phase 6: Streaming & Suspense"))
|
||||
(p :class "text-sm text-stone-600" "Server streams partially-evaluated SX as IO resolves. Client renders available subtrees immediately, fills in suspended parts. Requires async-aware delimited continuations for suspension.")
|
||||
(p :class "text-sm text-stone-500 mt-1" "Depends on: Phase 5 (IO proxy), continuations spec."))
|
||||
(p :class "text-sm text-stone-600" "Server streams partially-evaluated SX as IO resolves. ~suspense component renders fallbacks, inline resolution scripts fill in content. Concurrent IO via asyncio, chunked transfer encoding.")
|
||||
(p :class "text-sm text-stone-500 mt-1" "Demo: " (a :href "/isomorphism/streaming" "/isomorphism/streaming")))
|
||||
|
||||
(div :class "rounded border border-stone-200 bg-stone-50 p-4"
|
||||
(div :class "flex items-center gap-2 mb-1"
|
||||
@@ -1575,7 +1575,9 @@
|
||||
(li (strong "Boundary enforcement: ") "boundary.sx + SX_BOUNDARY_STRICT=1 validates all primitives/IO/helpers at registration.")
|
||||
(li (strong "Dependency analysis: ") "deps.sx computes per-page component bundles — only definitions a page actually uses are sent.")
|
||||
(li (strong "IO detection: ") "deps.sx classifies every component as pure or IO-dependent. Server expands IO components, serializes pure ones for client.")
|
||||
(li (strong "Client-side routing: ") "router.sx matches URL patterns. Pure pages render instantly without server roundtrips. Pages with :data fall through to server transparently.")))
|
||||
(li (strong "Client-side routing: ") "router.sx matches URL patterns. Pure pages render instantly without server roundtrips. Pages with :data fall through to server transparently.")
|
||||
(li (strong "Client IO proxy: ") "IO primitives registered on the client call back to the server via fetch. Components with IO deps can render client-side.")
|
||||
(li (strong "Streaming/suspense: ") "defpage :stream true enables chunked HTML. ~suspense placeholders show loading skeletons; __sxResolve() fills in content as IO completes.")))
|
||||
|
||||
;; -----------------------------------------------------------------------
|
||||
;; Phase 1
|
||||
@@ -1882,39 +1884,86 @@
|
||||
|
||||
(~doc-section :title "Phase 6: Streaming & Suspense" :id "phase-6"
|
||||
|
||||
(div :class "rounded border border-violet-200 bg-violet-50 p-4 mb-4"
|
||||
(p :class "text-violet-900 font-medium" "What it enables")
|
||||
(p :class "text-violet-800" "Server streams partially-evaluated SX as IO resolves. Client renders available subtrees immediately, fills in suspended parts. Like React Suspense but built on delimited continuations."))
|
||||
(div :class "rounded border border-green-300 bg-green-50 p-4 mb-4"
|
||||
(div :class "flex items-center gap-2 mb-2"
|
||||
(span :class "inline-block px-2 py-0.5 rounded text-xs font-bold bg-green-600 text-white uppercase" "Complete")
|
||||
(a :href "/isomorphism/streaming" :class "text-green-700 underline text-sm font-medium" "Live streaming demo"))
|
||||
(p :class "text-green-900 font-medium" "What it enables")
|
||||
(p :class "text-green-800" "Server streams partially-evaluated SX as IO resolves. Client renders available subtrees immediately with loading skeletons, fills in suspended parts as data arrives."))
|
||||
|
||||
(div :class "rounded border border-amber-200 bg-amber-50 p-3 mb-4"
|
||||
(p :class "text-amber-800 text-sm" (strong "Prerequisite: ") "Async-aware delimited continuations. The client solved IO suspension via JavaScript Promises (Phase 5), but the server needs continuations to suspend mid-evaluation when IO is encountered during streaming. Python's evaluator must capture the continuation at an IO call, emit a placeholder, schedule the IO, and resume the continuation when the result arrives."))
|
||||
(~doc-subsection :title "What was built"
|
||||
(ul :class "list-disc pl-5 text-stone-700 space-y-1"
|
||||
(li (code "~suspense") " component — renders fallback content with a stable DOM ID, replaced when resolution arrives")
|
||||
(li (code "defpage :stream true") " — opts a page into streaming response mode")
|
||||
(li (code "defpage :fallback expr") " — custom loading skeleton for streaming pages")
|
||||
(li (code "execute_page_streaming()") " — Quart async generator response that yields HTML chunks")
|
||||
(li (code "sx_page_streaming_parts()") " — splits the HTML shell into streamable parts")
|
||||
(li (code "Sx.resolveSuspense(id, sx)") " — client-side function to replace suspense placeholders")
|
||||
(li (code "window.__sxResolve") " bootstrap — queues resolutions that arrive before sx.js loads")
|
||||
(li "Concurrent IO: data eval + header eval run in parallel via " (code "asyncio.create_task"))
|
||||
(li "Completion-order streaming: whichever IO finishes first gets sent first via " (code "asyncio.wait(FIRST_COMPLETED)"))))
|
||||
|
||||
|
||||
(~doc-subsection :title "Approach"
|
||||
(~doc-subsection :title "Architecture"
|
||||
|
||||
(div :class "space-y-4"
|
||||
(div
|
||||
(h4 :class "font-semibold text-stone-700" "1. Continuation-based suspension")
|
||||
(p "When _aser encounters IO during slot evaluation, emit a placeholder with a suspension ID, schedule async resolution:")
|
||||
(~doc-code :code (highlight "(~suspense :id \"placeholder-123\"\n :fallback (div \"Loading...\"))" "lisp")))
|
||||
(h4 :class "font-semibold text-stone-700" "1. Suspense component")
|
||||
(p "When streaming, the server renders the page with " (code "~suspense") " placeholders instead of awaiting IO:")
|
||||
(~doc-code :code (highlight "(~app-body\n :header-rows (~suspense :id \"stream-headers\"\n :fallback (div :class \"h-12 bg-stone-200 animate-pulse\"))\n :content (~suspense :id \"stream-content\"\n :fallback (div :class \"p-8 animate-pulse\" ...)))" "lisp")))
|
||||
|
||||
(div
|
||||
(h4 :class "font-semibold text-stone-700" "2. Chunked transfer")
|
||||
(p "Quart async generator responses:")
|
||||
(p "Quart async generator response yields chunks in order:")
|
||||
(ol :class "list-decimal pl-5 text-stone-700 space-y-1"
|
||||
(li "HTML shell + CSS + component defs + page registry + suspense page SX + scripts (immediate)")
|
||||
(li "Resolution " (code "<script>") " tags as each IO completes")
|
||||
(li "Closing " (code "</body></html>"))))
|
||||
|
||||
(div
|
||||
(h4 :class "font-semibold text-stone-700" "3. Client resolution")
|
||||
(p "Each resolution chunk is an inline script:")
|
||||
(~doc-code :code (highlight "<script>\n window.__sxResolve(\"stream-content\",\n \"(~article :title \\\"Hello\\\")\")\n</script>" "html"))
|
||||
(p "The client parses the SX, renders to DOM, and replaces the suspense placeholder's children."))
|
||||
|
||||
(div
|
||||
(h4 :class "font-semibold text-stone-700" "4. Concurrent IO")
|
||||
(p "Data evaluation and header construction run in parallel. " (code "asyncio.wait(FIRST_COMPLETED)") " yields resolution chunks in whatever order IO completes — no artificial sequencing."))))
|
||||
|
||||
(~doc-subsection :title "Continuation foundation"
|
||||
(p "Delimited continuations (" (code "reset") "/" (code "shift") ") are implemented in the Python evaluator (async_eval.py lines 586-624) and available as special forms. Phase 6 uses the simpler pattern of concurrent IO + completion-order streaming, but the continuation machinery is in place for Phase 7's more sophisticated evaluation-level suspension."))
|
||||
|
||||
(~doc-subsection :title "Files"
|
||||
(ul :class "list-disc pl-5 text-stone-700 space-y-1 font-mono text-sm"
|
||||
(li "shared/sx/templates/pages.sx — ~suspense component definition")
|
||||
(li "shared/sx/types.py — PageDef.stream, PageDef.fallback_expr fields")
|
||||
(li "shared/sx/evaluator.py — defpage :stream/:fallback parsing")
|
||||
(li "shared/sx/pages.py — execute_page_streaming(), streaming route mounting")
|
||||
(li "shared/sx/helpers.py — sx_page_streaming_parts(), sx_streaming_resolve_script()")
|
||||
(li "shared/sx/ref/boot.sx — resolve-suspense spec (canonical)")
|
||||
(li "shared/sx/ref/bootstrap_js.py — resolveSuspense on Sx object, __sxPending/Resolve init")
|
||||
(li "shared/static/scripts/sx-browser.js — bootstrapped output (DO NOT EDIT)")
|
||||
(li "shared/sx/async_eval.py — reset/shift special forms (continuation foundation)")
|
||||
(li "sx/sx/streaming-demo.sx — demo content component")
|
||||
(li "sx/sxc/pages/docs.sx — streaming-demo defpage")
|
||||
(li "sx/sxc/pages/helpers.py — streaming-demo-data page helper")))
|
||||
|
||||
(~doc-subsection :title "Demonstration"
|
||||
(p "The " (a :href "/isomorphism/streaming" :class "text-violet-700 underline" "streaming demo page") " exercises the full pipeline:")
|
||||
(ol :class "list-decimal pl-5 text-stone-700 space-y-1"
|
||||
(li "Navigate to " (a :href "/isomorphism/streaming" :class "text-violet-700 underline" "/isomorphism/streaming"))
|
||||
(li "The page skeleton appears " (strong "instantly") " — animated loading skeletons fill the content area")
|
||||
(li "After ~1.5 seconds, the real content replaces the skeletons (streamed from server)")
|
||||
(li "Open the Network tab — observe " (code "Transfer-Encoding: chunked") " on the document response")
|
||||
(li "The document response shows multiple chunks arriving over time: shell first, then resolution scripts")))
|
||||
|
||||
(~doc-subsection :title "What to verify"
|
||||
(ul :class "list-disc pl-5 text-stone-700 space-y-1"
|
||||
(li "First chunk: HTML shell + synchronous content + placeholders")
|
||||
(li "Subsequent chunks: <script> tags replacing placeholders with resolved content")))
|
||||
|
||||
(div
|
||||
(h4 :class "font-semibold text-stone-700" "3. Client suspension rendering")
|
||||
(p "~suspense component renders fallback, listens for resolution via inline script or SSE (existing SSE infrastructure in orchestration.sx)."))
|
||||
|
||||
(div
|
||||
(h4 :class "font-semibold text-stone-700" "4. Priority-based IO")
|
||||
(p "Above-fold content resolves first. All IO starts concurrently (asyncio.create_task), results flushed in priority order."))))
|
||||
|
||||
(div :class "rounded border border-amber-200 bg-amber-50 p-3 mt-2"
|
||||
(p :class "text-amber-800 text-sm" (strong "Depends on: ") "Phase 5 (IO proxy for client rendering), async-aware delimited continuations (for server-side suspension), Phase 2 (IO analysis for priority).")))
|
||||
(li (strong "Instant shell: ") "The page HTML arrives immediately — no waiting for the 1.5s data fetch")
|
||||
(li (strong "Suspense placeholders: ") "The " (code "~suspense") " component renders a " (code "data-suspense") " wrapper with animated fallback content")
|
||||
(li (strong "Resolution: ") "The " (code "__sxResolve()") " inline script replaces the placeholder with real rendered content")
|
||||
(li (strong "Chunked encoding: ") "Network tab shows the document as a chunked response with multiple frames")
|
||||
(li (strong "Concurrent IO: ") "Header and content resolve independently — whichever finishes first appears first")
|
||||
(li (strong "HTMX fallback: ") "SX/HTMX requests bypass streaming and receive a standard response"))))
|
||||
|
||||
;; -----------------------------------------------------------------------
|
||||
;; Phase 7
|
||||
@@ -2041,7 +2090,7 @@
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/sx/ref/suspense.sx")
|
||||
(td :class "px-3 py-2 text-stone-700" "Streaming/suspension (new)")
|
||||
(td :class "px-3 py-2 text-stone-600" "5")))))))))))))
|
||||
(td :class "px-3 py-2 text-stone-600" "5"))))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; SX CI Pipeline
|
||||
|
||||
60
sx/sx/streaming-demo.sx
Normal file
60
sx/sx/streaming-demo.sx
Normal file
@@ -0,0 +1,60 @@
|
||||
;; Streaming & Suspense demo — Phase 6
|
||||
;;
|
||||
;; This page uses :stream true to enable chunked transfer encoding.
|
||||
;; The browser receives the HTML shell immediately with loading skeletons,
|
||||
;; then the content fills in when the (deliberately slow) data resolves.
|
||||
;;
|
||||
;; The :data expression simulates 1.5s IO delay. Without streaming, the
|
||||
;; browser would wait the full 1.5s before seeing anything. With streaming,
|
||||
;; the page skeleton appears instantly.
|
||||
|
||||
(defcomp ~streaming-demo-content (&key streamed-at message items)
|
||||
(div :class "space-y-8"
|
||||
(div :class "border-b border-stone-200 pb-6"
|
||||
(h1 :class "text-2xl font-bold text-stone-900" "Streaming & Suspense Demo")
|
||||
(p :class "mt-2 text-stone-600"
|
||||
"This page uses " (code :class "bg-stone-100 px-1 rounded text-violet-700" ":stream true")
|
||||
" in its defpage declaration. The browser receives the page skeleton instantly, "
|
||||
"then content fills in as IO resolves."))
|
||||
|
||||
;; Timestamp proves this was streamed
|
||||
(div :class "rounded-lg border border-green-200 bg-green-50 p-5 space-y-3"
|
||||
(h2 :class "text-lg font-semibold text-green-900" "Streamed Content")
|
||||
(p :class "text-green-800" message)
|
||||
(p :class "text-green-700 text-sm"
|
||||
"Data resolved at: " (code :class "bg-green-100 px-1 rounded" streamed-at))
|
||||
(p :class "text-green-700 text-sm"
|
||||
"This content arrived via a " (code :class "bg-green-100 px-1 rounded" "<script>__sxResolve(...)</script>")
|
||||
" chunk streamed after the initial HTML shell."))
|
||||
|
||||
;; Flow diagram
|
||||
(div :class "space-y-4"
|
||||
(h2 :class "text-lg font-semibold text-stone-800" "Streaming Flow")
|
||||
(div :class "grid gap-3"
|
||||
(map (fn (item)
|
||||
(div :class "flex items-start gap-3 rounded-lg border border-stone-200 bg-white p-4"
|
||||
(div :class "flex-shrink-0 w-8 h-8 rounded-full bg-violet-100 flex items-center justify-center text-violet-700 font-bold text-sm"
|
||||
(get item "label"))
|
||||
(p :class "text-stone-700 text-sm pt-1" (get item "detail"))))
|
||||
items)))
|
||||
|
||||
;; How it works
|
||||
(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 Streaming Works")
|
||||
(ol :class "list-decimal list-inside text-blue-800 space-y-2 text-sm"
|
||||
(li "Server starts data fetch and header fetch " (em "concurrently"))
|
||||
(li "HTML shell with " (code "~suspense") " placeholders is sent immediately")
|
||||
(li "Browser loads sx-browser.js, renders the page with loading skeletons")
|
||||
(li "Data IO completes — server sends " (code "<script>__sxResolve(\"stream-content\", ...)</script>"))
|
||||
(li "sx.js calls " (code "Sx.resolveSuspense()") " — replaces skeleton with real content")
|
||||
(li "Header IO completes — same process for header area")))
|
||||
|
||||
;; Technical details
|
||||
(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" "Implementation details")
|
||||
(ul :class "list-disc list-inside text-amber-700 space-y-1"
|
||||
(li (code "defpage :stream true") " — opts the page into streaming response")
|
||||
(li (code "~suspense :id \"...\" :fallback (...)") " — renders loading skeleton until resolved")
|
||||
(li "Quart async generator response — yields chunks as they become available")
|
||||
(li "Resolution via " (code "__sxResolve(id, sx)") " inline scripts in the stream")
|
||||
(li "Falls back to standard (non-streaming) response for SX/HTMX requests")))))
|
||||
@@ -6,7 +6,7 @@
|
||||
;; Overview page
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~testing-overview-content (&key server-results framework-source eval-source parser-source router-source render-source)
|
||||
(defcomp ~testing-overview-content (&key server-results framework-source eval-source parser-source router-source render-source deps-source engine-source)
|
||||
(~doc-page :title "Testing"
|
||||
(div :class "space-y-8"
|
||||
|
||||
@@ -35,6 +35,8 @@
|
||||
+-- test-parser.sx 39 tests: tokenizer, parser, serializer
|
||||
+-- test-router.sx 18 tests: route matching + param extraction
|
||||
+-- test-render.sx 23 tests: HTML rendering + components
|
||||
+-- test-deps.sx 33 tests: dependency analysis + IO detection
|
||||
+-- test-engine.sx 37 tests: trigger/swap/retry parsing
|
||||
|
||||
Runners:
|
||||
run.js Node.js — injects platform fns, runs specs
|
||||
@@ -51,7 +53,9 @@ Platform functions (5 total):
|
||||
Per-spec platform functions:
|
||||
parser: sx-parse, sx-serialize, make-symbol, make-keyword, ...
|
||||
router: (none — pure spec, uses bootstrapped functions)
|
||||
render: render-html (wraps parse + render-to-html)")))
|
||||
render: render-html (wraps parse + render-to-html)
|
||||
deps: test-env (returns current evaluation environment)
|
||||
engine: (none — pure spec, uses bootstrapped functions)")))
|
||||
|
||||
;; Server results
|
||||
(when server-results
|
||||
@@ -86,6 +90,8 @@ Per-spec platform functions:
|
||||
(textarea :id "test-spec-parser" :style "display:none" parser-source)
|
||||
(textarea :id "test-spec-router" :style "display:none" router-source)
|
||||
(textarea :id "test-spec-render" :style "display:none" render-source)
|
||||
(textarea :id "test-spec-deps" :style "display:none" deps-source)
|
||||
(textarea :id "test-spec-engine" :style "display:none" engine-source)
|
||||
(script :src (asset-url "/scripts/sx-test-runner.js")))
|
||||
|
||||
;; Test spec index
|
||||
@@ -107,7 +113,15 @@ Per-spec platform functions:
|
||||
(a :href "/testing/render" :class "block rounded-lg border border-stone-200 p-5 hover:border-violet-300 hover:bg-violet-50 transition-colors"
|
||||
(h3 :class "font-semibold text-stone-800" "Renderer")
|
||||
(p :class "text-sm text-stone-500" "23 tests — elements, attributes, void elements, fragments, escaping, control flow, components")
|
||||
(p :class "text-xs text-violet-600 mt-1" "test-render.sx"))))
|
||||
(p :class "text-xs text-violet-600 mt-1" "test-render.sx"))
|
||||
(a :href "/testing/deps" :class "block rounded-lg border border-stone-200 p-5 hover:border-violet-300 hover:bg-violet-50 transition-colors"
|
||||
(h3 :class "font-semibold text-stone-800" "Dependencies")
|
||||
(p :class "text-sm text-stone-500" "33 tests — scan-refs, transitive-deps, components-needed, IO detection, purity classification")
|
||||
(p :class "text-xs text-violet-600 mt-1" "test-deps.sx"))
|
||||
(a :href "/testing/engine" :class "block rounded-lg border border-stone-200 p-5 hover:border-violet-300 hover:bg-violet-50 transition-colors"
|
||||
(h3 :class "font-semibold text-stone-800" "Engine")
|
||||
(p :class "text-sm text-stone-500" "37 tests — parse-time, trigger specs, swap specs, retry logic, param filtering")
|
||||
(p :class "text-xs text-violet-600 mt-1" "test-engine.sx"))))
|
||||
|
||||
;; What it proves
|
||||
(div :class "rounded-lg border border-blue-200 bg-blue-50 p-5 space-y-3"
|
||||
|
||||
@@ -458,6 +458,26 @@
|
||||
:selected "Async IO")
|
||||
:content (~async-io-demo-content))
|
||||
|
||||
(defpage streaming-demo
|
||||
:path "/isomorphism/streaming"
|
||||
:auth :public
|
||||
:stream true
|
||||
:layout (:sx-section
|
||||
:section "Isomorphism"
|
||||
:sub-label "Isomorphism"
|
||||
:sub-href "/isomorphism/"
|
||||
:sub-nav (~section-nav :items isomorphism-nav-items :current "Streaming")
|
||||
:selected "Streaming")
|
||||
:fallback (div :class "p-8 space-y-4 animate-pulse"
|
||||
(div :class "h-8 bg-stone-200 rounded w-1/3")
|
||||
(div :class "h-4 bg-stone-200 rounded w-2/3")
|
||||
(div :class "h-64 bg-stone-200 rounded"))
|
||||
:data (streaming-demo-data)
|
||||
:content (~streaming-demo-content
|
||||
:streamed-at streamed-at
|
||||
:message message
|
||||
:items items))
|
||||
|
||||
;; Wildcard must come AFTER specific routes (first-match routing)
|
||||
(defpage isomorphism-page
|
||||
:path "/isomorphism/<slug>"
|
||||
@@ -535,7 +555,9 @@
|
||||
:eval-source eval-source
|
||||
:parser-source parser-source
|
||||
:router-source router-source
|
||||
:render-source render-source))
|
||||
:render-source render-source
|
||||
:deps-source deps-source
|
||||
:engine-source engine-source))
|
||||
|
||||
(defpage testing-page
|
||||
:path "/testing/<slug>"
|
||||
@@ -552,6 +574,8 @@
|
||||
"parser" (run-modular-tests "parser")
|
||||
"router" (run-modular-tests "router")
|
||||
"render" (run-modular-tests "render")
|
||||
"deps" (run-modular-tests "deps")
|
||||
"engine" (run-modular-tests "engine")
|
||||
:else (dict))
|
||||
:content (case slug
|
||||
"eval" (~testing-spec-content
|
||||
@@ -582,6 +606,20 @@
|
||||
:spec-source spec-source
|
||||
:framework-source framework-source
|
||||
:server-results server-results)
|
||||
"deps" (~testing-spec-content
|
||||
:spec-name "deps"
|
||||
:spec-title "Dependency Analysis Tests"
|
||||
:spec-desc "33 tests covering component dependency analysis — scan-refs, scan-components-from-source, transitive-deps, components-needed, scan-io-refs, and component-pure? classification."
|
||||
:spec-source spec-source
|
||||
:framework-source framework-source
|
||||
:server-results server-results)
|
||||
"engine" (~testing-spec-content
|
||||
:spec-name "engine"
|
||||
:spec-title "Engine Tests"
|
||||
:spec-desc "37 tests covering engine pure functions — parse-time, parse-trigger-spec, default-trigger, parse-swap-spec, parse-retry-spec, next-retry-ms, and filter-params."
|
||||
:spec-source spec-source
|
||||
:framework-source framework-source
|
||||
:server-results server-results)
|
||||
"runners" (~testing-runners-content)
|
||||
:else (~testing-overview-content
|
||||
:server-results server-results)))
|
||||
|
||||
@@ -26,6 +26,7 @@ def _register_sx_helpers() -> None:
|
||||
"data-test-data": _data_test_data,
|
||||
"run-spec-tests": _run_spec_tests,
|
||||
"run-modular-tests": _run_modular_tests,
|
||||
"streaming-demo-data": _streaming_demo_data,
|
||||
})
|
||||
|
||||
|
||||
@@ -706,6 +707,8 @@ def _run_modular_tests(spec_name: str) -> dict:
|
||||
"parser": {"file": "test-parser.sx", "needs": ["sx-parse"]},
|
||||
"router": {"file": "test-router.sx", "needs": []},
|
||||
"render": {"file": "test-render.sx", "needs": ["render-html"]},
|
||||
"deps": {"file": "test-deps.sx", "needs": []},
|
||||
"engine": {"file": "test-engine.sx", "needs": []},
|
||||
}
|
||||
|
||||
specs_to_run = list(SPECS.keys()) if spec_name == "all" else [spec_name]
|
||||
@@ -720,7 +723,7 @@ def _run_modular_tests(spec_name: str) -> dict:
|
||||
if not spec:
|
||||
continue
|
||||
|
||||
# Load router from bootstrap if needed
|
||||
# Load module functions from bootstrap
|
||||
if sn == "router":
|
||||
try:
|
||||
from shared.sx.ref.sx_ref import (
|
||||
@@ -739,6 +742,46 @@ def _run_modular_tests(spec_name: str) -> dict:
|
||||
env["make-route-segment"] = make_route_segment
|
||||
except ImportError:
|
||||
eval_file("router.sx")
|
||||
elif sn == "deps":
|
||||
try:
|
||||
from shared.sx.ref.sx_ref import (
|
||||
scan_refs, scan_components_from_source,
|
||||
transitive_deps, compute_all_deps,
|
||||
components_needed, page_component_bundle,
|
||||
page_css_classes, scan_io_refs,
|
||||
transitive_io_refs, compute_all_io_refs,
|
||||
component_pure_p,
|
||||
)
|
||||
env["scan-refs"] = scan_refs
|
||||
env["scan-components-from-source"] = scan_components_from_source
|
||||
env["transitive-deps"] = transitive_deps
|
||||
env["compute-all-deps"] = compute_all_deps
|
||||
env["components-needed"] = components_needed
|
||||
env["page-component-bundle"] = page_component_bundle
|
||||
env["page-css-classes"] = page_css_classes
|
||||
env["scan-io-refs"] = scan_io_refs
|
||||
env["transitive-io-refs"] = transitive_io_refs
|
||||
env["compute-all-io-refs"] = compute_all_io_refs
|
||||
env["component-pure?"] = component_pure_p
|
||||
env["test-env"] = lambda: env
|
||||
except ImportError:
|
||||
eval_file("deps.sx")
|
||||
env["test-env"] = lambda: env
|
||||
elif sn == "engine":
|
||||
try:
|
||||
from shared.sx.ref.sx_ref import (
|
||||
parse_time, parse_trigger_spec, default_trigger,
|
||||
parse_swap_spec, parse_retry_spec, filter_params,
|
||||
)
|
||||
env["parse-time"] = parse_time
|
||||
env["parse-trigger-spec"] = parse_trigger_spec
|
||||
env["default-trigger"] = default_trigger
|
||||
env["parse-swap-spec"] = parse_swap_spec
|
||||
env["parse-retry-spec"] = parse_retry_spec
|
||||
env["next-retry-ms"] = lambda cur, cap: min(cur * 2, cap)
|
||||
env["filter-params"] = filter_params
|
||||
except ImportError:
|
||||
eval_file("engine.sx")
|
||||
|
||||
eval_file(spec["file"])
|
||||
|
||||
@@ -762,6 +805,8 @@ def _run_modular_tests(spec_name: str) -> dict:
|
||||
result["parser-source"] = _read_spec_file("test-parser.sx")
|
||||
result["router-source"] = _read_spec_file("test-router.sx")
|
||||
result["render-source"] = _read_spec_file("test-render.sx")
|
||||
result["deps-source"] = _read_spec_file("test-deps.sx")
|
||||
result["engine-source"] = _read_spec_file("test-engine.sx")
|
||||
else:
|
||||
spec = SPECS.get(spec_name)
|
||||
if spec:
|
||||
@@ -791,3 +836,21 @@ def _data_test_data() -> dict:
|
||||
"phase": "Phase 4 — Client Async & IO Bridge",
|
||||
"transport": "SX wire format (text/sx)",
|
||||
}
|
||||
|
||||
|
||||
async def _streaming_demo_data() -> dict:
|
||||
"""Simulate slow IO for streaming demo — 1.5s delay."""
|
||||
import asyncio
|
||||
await asyncio.sleep(1.5)
|
||||
from datetime import datetime, timezone
|
||||
return {
|
||||
"streamed-at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
"message": "This content was streamed after a 1.5 second delay.",
|
||||
"items": [
|
||||
{"label": "Shell", "detail": "HTML shell with suspense placeholders sent immediately"},
|
||||
{"label": "Bootstrap", "detail": "sx-browser.js loads, renders fallback skeletons"},
|
||||
{"label": "IO Start", "detail": "Data fetch and header fetch run concurrently"},
|
||||
{"label": "Resolve", "detail": "As each IO completes, <script> chunk replaces placeholder"},
|
||||
{"label": "Done", "detail": "Page fully rendered — all suspense resolved"},
|
||||
],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user