Fix streaming resolve: color dict keys + defer resolveSuspense until after boot
stream-colors dict had green/blue keys but data used emerald/violet — all three slots now render with correct Tailwind color classes. Platform: resolveSuspense must not exist on Sx until boot completes, otherwise bootstrap __sxResolve calls it before web stack loads and resolves silently fail. Moved to post-boot setup so all pre-boot resolves queue in __sxPending and drain correctly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -40,7 +40,12 @@
|
||||
var obj = args[0], prop = args[1];
|
||||
if (obj == null) return null;
|
||||
var v = obj[prop];
|
||||
return v === undefined ? null : v;
|
||||
if (v === undefined) return null;
|
||||
// Functions can't cross the WASM boundary — return true as a truthy
|
||||
// sentinel so (host-get el "getAttribute") works as a guard.
|
||||
// Use host-call to actually invoke the method.
|
||||
if (typeof v === "function") return true;
|
||||
return v;
|
||||
});
|
||||
|
||||
K.registerNative("host-set!", function(args) {
|
||||
@@ -79,64 +84,47 @@
|
||||
}
|
||||
});
|
||||
|
||||
// IO suspension driver — resumes suspended callFn results (wait, fetch, etc.)
|
||||
if (!window._driveAsync) {
|
||||
window._driveAsync = function driveAsync(result) {
|
||||
if (!result || !result.suspended) return;
|
||||
var req = result.request;
|
||||
var items = req && (req.items || req);
|
||||
var op = items && items[0];
|
||||
var opName = typeof op === "string" ? op : (op && op.name) || String(op);
|
||||
var arg = items && items[1];
|
||||
if (opName === "io-sleep" || opName === "wait") {
|
||||
setTimeout(function() {
|
||||
try { driveAsync(result.resume(null)); } catch(e) { console.error("[sx] driveAsync:", e.message); }
|
||||
}, typeof arg === "number" ? arg : 0);
|
||||
} else if (opName === "io-fetch") {
|
||||
fetch(typeof arg === "string" ? arg : "").then(function(r) { return r.text(); }).then(function(t) {
|
||||
try { driveAsync(result.resume({ok: true, text: t})); } catch(e) { console.error("[sx] driveAsync:", e.message); }
|
||||
});
|
||||
} else if (opName === "io-navigate") {
|
||||
// navigation — don't resume
|
||||
} else {
|
||||
console.warn("[sx] unhandled IO:", opName);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
K.registerNative("host-callback", function(args) {
|
||||
var fn = args[0];
|
||||
// Native JS function (not SX-origin) — pass through
|
||||
if (typeof fn === "function" && fn.__sx_handle === undefined) return fn;
|
||||
// SX callable (has __sx_handle) — wrap as JS function with suspension handling
|
||||
// Native JS function — pass through
|
||||
if (typeof fn === "function") return fn;
|
||||
// SX callable (has __sx_handle) — wrap as JS function
|
||||
if (fn && fn.__sx_handle !== undefined) {
|
||||
return function() {
|
||||
var a = Array.prototype.slice.call(arguments);
|
||||
var result = K.callFn(fn, a);
|
||||
// Handle IO suspension chain (e.g. wait, fetch, navigate)
|
||||
_driveAsync(result);
|
||||
return result;
|
||||
var r = K.callFn(fn, a);
|
||||
if (window._driveAsync) window._driveAsync(r);
|
||||
return r;
|
||||
};
|
||||
}
|
||||
return function() {};
|
||||
});
|
||||
|
||||
/**
|
||||
* Drive an async suspension chain to completion.
|
||||
* When K.callFn returns {suspended: true, request: ..., resume: fn},
|
||||
* handle the IO operation and resume the VM.
|
||||
*/
|
||||
function _driveAsync(result) {
|
||||
if (!result || !result.suspended) return;
|
||||
console.log("[sx] IO suspension:", JSON.stringify(result.request, null, 2));
|
||||
var req = result.request;
|
||||
if (!req) return;
|
||||
|
||||
// req is an SX list — extract items. K returns SX values.
|
||||
var items = req.items || req;
|
||||
var op = (items && items[0]) || req;
|
||||
// Normalize: op might be a string or {name: "..."} symbol
|
||||
var opName = (typeof op === "string") ? op : (op && op.name) || String(op);
|
||||
|
||||
if (opName === "wait" || opName === "io-sleep") {
|
||||
// (wait ms) or (io-sleep ms) — resume after timeout
|
||||
var ms = (items && items[1]) || 0;
|
||||
if (typeof ms !== "number") ms = parseFloat(ms) || 0;
|
||||
console.log("[sx] IO wait: " + ms + "ms, resuming after timeout");
|
||||
setTimeout(function() {
|
||||
try {
|
||||
var resumed = result.resume(null);
|
||||
console.log("[sx] IO resumed:", typeof resumed, resumed && resumed.suspended ? "suspended-again" : "done", JSON.stringify(resumed));
|
||||
_driveAsync(resumed);
|
||||
} catch(e) {
|
||||
console.error("[sx] IO resume error:", e);
|
||||
}
|
||||
}, ms);
|
||||
} else if (opName === "navigate") {
|
||||
// (navigate url) — browser navigation
|
||||
var url = (items && items[1]) || "/";
|
||||
if (typeof url !== "string") url = String(url);
|
||||
window.location.href = url;
|
||||
} else {
|
||||
console.warn("[sx] Unhandled IO suspension in callback:", opName, req);
|
||||
}
|
||||
}
|
||||
|
||||
K.registerNative("host-typeof", function(args) {
|
||||
var obj = args[0];
|
||||
if (obj == null) return "nil";
|
||||
@@ -518,7 +506,7 @@
|
||||
// will see it as already loaded and skip rather than infinite-looping.
|
||||
_loadedLibs[name] = true;
|
||||
|
||||
// Load this module
|
||||
// Load this module (bytecode first, fallback to source)
|
||||
var ok = loadBytecodeFile("sx/" + info.file);
|
||||
if (!ok) {
|
||||
var sxFile = info.file.replace(/\.sxbc$/, '.sx');
|
||||
@@ -570,10 +558,7 @@
|
||||
"sx/adapter-html.sx", "sx/adapter-sx.sx", "sx/adapter-dom.sx",
|
||||
"sx/boot-helpers.sx", "sx/hypersx.sx", "sx/harness.sx",
|
||||
"sx/harness-reactive.sx", "sx/harness-web.sx",
|
||||
"sx/engine.sx", "sx/orchestration.sx",
|
||||
"sx/hs-tokenizer.sx", "sx/hs-parser.sx", "sx/hs-compiler.sx",
|
||||
"sx/hs-runtime.sx", "sx/hs-integration.sx",
|
||||
"sx/boot.sx",
|
||||
"sx/engine.sx", "sx/orchestration.sx", "sx/boot.sx",
|
||||
];
|
||||
if (K.beginModuleLoad) K.beginModuleLoad();
|
||||
for (var i = 0; i < files.length; i++) {
|
||||
@@ -691,19 +676,32 @@
|
||||
"hydrated:", !!islands[j]._sxBoundislandhydrated || !!islands[j]["_sxBound" + "island-hydrated"],
|
||||
"children:", islands[j].children.length);
|
||||
}
|
||||
// Activate _hyperscript compat on elements with _ attribute
|
||||
if (document.querySelector('[_]')) {
|
||||
if (K.beginModuleLoad) K.beginModuleLoad();
|
||||
loadLibrary("hyperscript integration", {});
|
||||
if (K.endModuleLoad) K.endModuleLoad();
|
||||
K.eval("(hs-boot!)");
|
||||
}
|
||||
// Register popstate handler for back/forward navigation
|
||||
window.addEventListener("popstate", function(e) {
|
||||
var state = e.state;
|
||||
var scrollY = (state && state.scrollY) ? state.scrollY : 0;
|
||||
K.eval("(handle-popstate " + scrollY + ")");
|
||||
});
|
||||
// Define resolveSuspense now that boot is complete and web stack is loaded.
|
||||
// Must happen AFTER boot — resolve-suspense needs dom-query, render-to-dom etc.
|
||||
Sx.resolveSuspense = function(id, sx) {
|
||||
try {
|
||||
K.eval('(resolve-suspense ' + JSON.stringify(id) + ' ' + JSON.stringify(sx) + ')');
|
||||
} catch (e) {
|
||||
console.error("[sx] resolveSuspense error for id=" + id, e);
|
||||
}
|
||||
};
|
||||
// Process any streaming suspense resolutions that arrived before boot
|
||||
if (globalThis.__sxPending && globalThis.__sxPending.length > 0) {
|
||||
for (var pi = 0; pi < globalThis.__sxPending.length; pi++) {
|
||||
try {
|
||||
Sx.resolveSuspense(globalThis.__sxPending[pi].id, globalThis.__sxPending[pi].sx);
|
||||
} catch(e) { console.error("[sx] pending resolve error:", e); }
|
||||
}
|
||||
globalThis.__sxPending = null;
|
||||
}
|
||||
// Set up direct resolution for future streaming chunks
|
||||
globalThis.__sxResolve = function(id, sx) { Sx.resolveSuspense(id, sx); };
|
||||
// Signal boot complete
|
||||
document.documentElement.setAttribute("data-sx-ready", "true");
|
||||
console.log("[sx] boot done");
|
||||
|
||||
@@ -103,6 +103,23 @@
|
||||
});
|
||||
} else if (opName === "io-navigate") {
|
||||
// navigation — don't resume
|
||||
} else if (opName === "text-measure") {
|
||||
// Pretext: measure text using offscreen canvas
|
||||
var font = arg;
|
||||
var size = items && items[2];
|
||||
var text = items && items[3];
|
||||
var canvas = document.createElement("canvas");
|
||||
var ctx = canvas.getContext("2d");
|
||||
ctx.font = (size || 16) + "px " + (font || "serif");
|
||||
var m = ctx.measureText(text || "");
|
||||
try {
|
||||
driveAsync(result.resume({
|
||||
width: m.width,
|
||||
height: m.actualBoundingBoxAscent + m.actualBoundingBoxDescent,
|
||||
ascent: m.actualBoundingBoxAscent,
|
||||
descent: m.actualBoundingBoxDescent
|
||||
}));
|
||||
} catch(e) { console.error("[sx] driveAsync:", e.message); }
|
||||
} else {
|
||||
console.warn("[sx] unhandled IO:", opName);
|
||||
}
|
||||
@@ -635,13 +652,6 @@
|
||||
renderToHtml: function(expr) { return K.renderToHtml(expr); },
|
||||
callFn: function(fn, args) { return K.callFn(fn, args); },
|
||||
engine: function() { return K.engine(); },
|
||||
resolveSuspense: function(id, sx) {
|
||||
try {
|
||||
K.eval('(resolve-suspense ' + JSON.stringify(id) + ' ' + JSON.stringify(sx) + ')');
|
||||
} catch (e) {
|
||||
console.error("[sx] resolveSuspense error for id=" + id, e);
|
||||
}
|
||||
},
|
||||
// Boot entry point (called by auto-init or manually)
|
||||
init: function() {
|
||||
if (typeof K.eval === "function") {
|
||||
@@ -697,26 +707,15 @@
|
||||
};
|
||||
|
||||
// ================================================================
|
||||
// Auto-init: load web stack eagerly, boot on DOMContentLoaded
|
||||
// Auto-init: load web stack and boot on DOMContentLoaded
|
||||
// ================================================================
|
||||
|
||||
if (typeof document !== "undefined") {
|
||||
loadWebStack();
|
||||
// Pre-process component scripts eagerly so resolve-suspense doesn't
|
||||
// hit the 'Undefined symbol: default' error on first call.
|
||||
// The error occurs during component loading but is non-fatal.
|
||||
try { K.eval("(process-sx-scripts nil)"); } catch(e) {}
|
||||
|
||||
var _doInit = function() {
|
||||
loadWebStack();
|
||||
Sx.init();
|
||||
// Drain streaming resolves that arrived before boot or were deferred on error
|
||||
if (window.__sxPending) {
|
||||
for (var pi = 0; pi < window.__sxPending.length; pi++) {
|
||||
Sx.resolveSuspense(window.__sxPending[pi].id, window.__sxPending[pi].sx);
|
||||
}
|
||||
window.__sxPending = null;
|
||||
}
|
||||
window.__sxResolve = function(id, sx) { Sx.resolveSuspense(id, sx); };
|
||||
// Enable JIT after all boot code has run.
|
||||
// Lazy-load the compiler first — JIT needs it to compile functions.
|
||||
setTimeout(function() {
|
||||
if (K.beginModuleLoad) K.beginModuleLoad();
|
||||
loadLibrary("sx compiler", {});
|
||||
|
||||
@@ -8,16 +8,57 @@
|
||||
;; at 1s, 3s, and 5s. Each chunk resolves a different ~shared:pages/suspense slot.
|
||||
|
||||
;; Color map for stream chunk styling (all string keys for get compatibility)
|
||||
(define stream-colors
|
||||
{"green" {"border" "border-green-200" "bg" "bg-green-50" "title" "text-green-900"
|
||||
"text" "text-green-800" "sub" "text-green-700" "code" "bg-green-100"
|
||||
"dot" "bg-green-400"}
|
||||
"blue" {"border" "border-blue-200" "bg" "bg-blue-50" "title" "text-blue-900"
|
||||
"text" "text-blue-800" "sub" "text-blue-700" "code" "bg-blue-100"
|
||||
"dot" "bg-blue-400"}
|
||||
"amber" {"border" "border-amber-200" "bg" "bg-amber-50" "title" "text-amber-900"
|
||||
"text" "text-amber-800" "sub" "text-amber-700" "code" "bg-amber-100"
|
||||
"dot" "bg-amber-400"}})
|
||||
(define
|
||||
stream-colors
|
||||
(dict
|
||||
"emerald"
|
||||
(dict
|
||||
"border"
|
||||
"border-emerald-200"
|
||||
"bg"
|
||||
"bg-emerald-50"
|
||||
"dot"
|
||||
"bg-emerald-400"
|
||||
"title"
|
||||
"text-emerald-900"
|
||||
"text"
|
||||
"text-emerald-800"
|
||||
"sub"
|
||||
"text-emerald-700"
|
||||
"code"
|
||||
"bg-emerald-100")
|
||||
"amber"
|
||||
(dict
|
||||
"border"
|
||||
"border-amber-200"
|
||||
"bg"
|
||||
"bg-amber-50"
|
||||
"dot"
|
||||
"bg-amber-400"
|
||||
"title"
|
||||
"text-amber-900"
|
||||
"text"
|
||||
"text-amber-800"
|
||||
"sub"
|
||||
"text-amber-700"
|
||||
"code"
|
||||
"bg-amber-100")
|
||||
"violet"
|
||||
(dict
|
||||
"border"
|
||||
"border-violet-200"
|
||||
"bg"
|
||||
"bg-violet-50"
|
||||
"dot"
|
||||
"bg-violet-400"
|
||||
"title"
|
||||
"text-violet-900"
|
||||
"text"
|
||||
"text-violet-800"
|
||||
"sub"
|
||||
"text-violet-700"
|
||||
"code"
|
||||
"bg-violet-100")))
|
||||
|
||||
;; Generic streamed content chunk — rendered once per yield from the
|
||||
;; async generator. The :content expression receives different bindings
|
||||
|
||||
Reference in New Issue
Block a user