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:
2026-04-12 09:58:18 +00:00
parent 6e27442d57
commit 56855eee7f
3 changed files with 131 additions and 93 deletions

View File

@@ -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");

View File

@@ -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", {});

View File

@@ -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