Fix WASM reactive signals: unify context/scope, fix flush-subscribers
Three root causes for reactive attribute updates not propagating in WASM: 1. `context` CEK special form only searched kont provide frames, missing `scope-push!` entries in the native scope_stacks hashtable. Unified by adding scope_stacks fallback to step_sf_context. 2. `flush-subscribers` used bare `(sub)` call which failed to invoke complex closures in for-each HO callbacks. Changed to `(cek-call sub nil)`. 3. Test eagerly evaluated `(deref s)` before render-to-dom saw it. Fixed tests to use quoted expressions matching real browser boot. WASM native: 10/10, WASM shell: 26/26. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
308
hosts/ocaml/browser/test_wasm.sh
Executable file
308
hosts/ocaml/browser/test_wasm.sh
Executable file
@@ -0,0 +1,308 @@
|
||||
#!/bin/bash
|
||||
# WASM kernel tests in Node.js — verifies the compiled sx_browser.bc.js
|
||||
# handles HTML tags, rendering, signals, and components correctly.
|
||||
# Does NOT require a running server or browser.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/../../.."
|
||||
|
||||
node -e '
|
||||
// --- DOM stubs that track state ---
|
||||
function makeElement(tag) {
|
||||
var el = {
|
||||
tagName: tag,
|
||||
_attrs: {},
|
||||
_children: [],
|
||||
style: {},
|
||||
childNodes: [],
|
||||
children: [],
|
||||
textContent: "",
|
||||
setAttribute: function(k, v) { el._attrs[k] = v; },
|
||||
getAttribute: function(k) { return el._attrs[k] || null; },
|
||||
removeAttribute: function(k) { delete el._attrs[k]; },
|
||||
appendChild: function(c) { el._children.push(c); el.childNodes.push(c); el.children.push(c); return c; },
|
||||
insertBefore: function(c, ref) { el._children.push(c); el.childNodes.push(c); el.children.push(c); return c; },
|
||||
removeChild: function(c) { return c; },
|
||||
replaceChild: function(n, o) { return n; },
|
||||
cloneNode: function() { return makeElement(tag); },
|
||||
addEventListener: function() {},
|
||||
removeEventListener: function() {},
|
||||
dispatchEvent: function() {},
|
||||
get innerHTML() {
|
||||
// Reconstruct from children for simple cases
|
||||
return el._children.map(function(c) {
|
||||
if (c._isText) return c.textContent || "";
|
||||
if (c._isComment) return "<!--" + (c.textContent || "") + "-->";
|
||||
return c.outerHTML || "";
|
||||
}).join("");
|
||||
},
|
||||
set innerHTML(v) { el._children = []; el.childNodes = []; el.children = []; el.textContent = v; },
|
||||
get outerHTML() {
|
||||
var s = "<" + tag;
|
||||
var keys = Object.keys(el._attrs).sort();
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
s += " " + keys[i] + "=\"" + el._attrs[keys[i]] + "\"";
|
||||
}
|
||||
s += ">";
|
||||
var voids = ["br","hr","img","input","meta","link"];
|
||||
if (voids.indexOf(tag) >= 0) return s;
|
||||
s += el.innerHTML;
|
||||
s += "</" + tag + ">";
|
||||
return s;
|
||||
},
|
||||
dataset: new Proxy({}, {
|
||||
get: function(t, k) { return el._attrs["data-" + k.replace(/[A-Z]/g, function(c) { return "-" + c.toLowerCase(); })]; },
|
||||
set: function(t, k, v) { el._attrs["data-" + k.replace(/[A-Z]/g, function(c) { return "-" + c.toLowerCase(); })] = v; return true; }
|
||||
}),
|
||||
querySelectorAll: function() { return []; },
|
||||
querySelector: function() { return null; },
|
||||
};
|
||||
return el;
|
||||
}
|
||||
|
||||
global.window = global;
|
||||
global.document = {
|
||||
createElement: makeElement,
|
||||
createDocumentFragment: function() {
|
||||
var f = makeElement("fragment");
|
||||
f.tagName = undefined;
|
||||
return f;
|
||||
},
|
||||
head: makeElement("head"),
|
||||
body: makeElement("body"),
|
||||
querySelector: function() { return null; },
|
||||
querySelectorAll: function() { return []; },
|
||||
createTextNode: function(s) { return {_isText:true, textContent:String(s), nodeType:3}; },
|
||||
addEventListener: function() {},
|
||||
createComment: function(s) { return {_isComment:true, textContent:s||"", nodeType:8}; },
|
||||
getElementsByTagName: function() { return []; },
|
||||
};
|
||||
global.localStorage = {getItem:function(){return null},setItem:function(){},removeItem:function(){}};
|
||||
global.CustomEvent = class { constructor(n,o){this.type=n;this.detail=(o||{}).detail||{}} };
|
||||
global.MutationObserver = class { observe(){} disconnect(){} };
|
||||
global.requestIdleCallback = function(fn) { return setTimeout(fn,0); };
|
||||
global.matchMedia = function() { return {matches:false}; };
|
||||
global.navigator = {serviceWorker:{register:function(){return Promise.resolve()}}};
|
||||
global.location = {href:"",pathname:"/",hostname:"localhost"};
|
||||
global.history = {pushState:function(){},replaceState:function(){}};
|
||||
global.fetch = function() { return Promise.resolve({ok:true,text:function(){return Promise.resolve("")}}); };
|
||||
global.setTimeout = setTimeout;
|
||||
global.clearTimeout = clearTimeout;
|
||||
global.XMLHttpRequest = class { open(){} send(){} };
|
||||
|
||||
// --- Load kernel ---
|
||||
require("./shared/static/wasm/sx_browser.bc.js");
|
||||
var K = globalThis.SxKernel;
|
||||
if (!K) { console.error("FAIL: SxKernel not found"); process.exit(1); }
|
||||
|
||||
// --- Register 8 FFI host primitives (normally done by sx-platform-2.js) ---
|
||||
K.registerNative("host-global", function(args) {
|
||||
var name = args[0];
|
||||
if (typeof globalThis !== "undefined" && name in globalThis) return globalThis[name];
|
||||
return null;
|
||||
});
|
||||
K.registerNative("host-get", function(args) {
|
||||
var obj = args[0], prop = args[1];
|
||||
if (obj == null) return null;
|
||||
var v = obj[prop];
|
||||
return v === undefined ? null : v;
|
||||
});
|
||||
K.registerNative("host-set!", function(args) {
|
||||
var obj = args[0], prop = args[1], val = args[2];
|
||||
if (obj != null) obj[prop] = val;
|
||||
return val;
|
||||
});
|
||||
K.registerNative("host-call", function(args) {
|
||||
var obj = args[0], method = args[1];
|
||||
var callArgs = args.slice(2);
|
||||
if (obj == null || typeof obj[method] !== "function") return null;
|
||||
var r = obj[method].apply(obj, callArgs);
|
||||
return r === undefined ? null : r;
|
||||
});
|
||||
K.registerNative("host-new", function(args) {
|
||||
var ctor = args[0];
|
||||
var ctorArgs = args.slice(1);
|
||||
return new (Function.prototype.bind.apply(ctor, [null].concat(ctorArgs)));
|
||||
});
|
||||
K.registerNative("host-callback", function(args) {
|
||||
var fn = args[0];
|
||||
return function() { return K.callFn(fn, Array.from(arguments)); };
|
||||
});
|
||||
K.registerNative("host-typeof", function(args) {
|
||||
return typeof args[0];
|
||||
});
|
||||
K.registerNative("host-await", function(args) { return args[0]; });
|
||||
|
||||
// Platform constants
|
||||
K.eval("(define SX_VERSION \"test-1.0\")");
|
||||
K.eval("(define SX_ENGINE \"ocaml-vm-test\")");
|
||||
K.eval("(define parse sx-parse)");
|
||||
K.eval("(define serialize sx-serialize)");
|
||||
|
||||
var pass = 0, fail = 0;
|
||||
function assert(name, got, expected) {
|
||||
if (got === expected) { pass++; }
|
||||
else { fail++; console.error("FAIL: " + name + "\n got: " + JSON.stringify(got) + "\n expected: " + JSON.stringify(expected)); }
|
||||
}
|
||||
function assertIncludes(name, got, substr) {
|
||||
if (typeof got === "string" && got.includes(substr)) { pass++; }
|
||||
else { fail++; console.error("FAIL: " + name + "\n got: " + JSON.stringify(got) + "\n expected to include: " + JSON.stringify(substr)); }
|
||||
}
|
||||
function assertNotError(name, got) {
|
||||
if (typeof got === "string" && got.startsWith("Error:")) { fail++; console.error("FAIL: " + name + ": " + got); }
|
||||
else { pass++; }
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Section 1: HTML tags and rendering
|
||||
// =====================================================================
|
||||
|
||||
assert("arithmetic", K.eval("(+ 1 2)"), 3);
|
||||
assert("string", K.eval("(str \"hello\" \" world\")"), "hello world");
|
||||
|
||||
// Tags as special forms — keywords preserved
|
||||
assert("div preserves keywords",
|
||||
K.eval("(inspect (div :class \"test\" \"hello\"))"),
|
||||
"(div :class \"test\" \"hello\")");
|
||||
|
||||
assert("span preserves keywords",
|
||||
K.eval("(inspect (span :id \"x\" \"content\"))"),
|
||||
"(span :id \"x\" \"content\")");
|
||||
|
||||
// render-to-html
|
||||
assert("render div+class", K.eval("(render-to-html (div :class \"card\" \"content\"))"), "<div class=\"card\">content</div>");
|
||||
assert("render h1+class", K.eval("(render-to-html (h1 :class \"title\" \"Hello\"))"), "<h1 class=\"title\">Hello</h1>");
|
||||
assert("render a+href", K.eval("(render-to-html (a :href \"/about\" \"About\"))"), "<a href=\"/about\">About</a>");
|
||||
assert("render nested", K.eval("(render-to-html (div :class \"outer\" (span :class \"inner\" \"text\")))"), "<div class=\"outer\"><span class=\"inner\">text</span></div>");
|
||||
assertIncludes("void element br", K.eval("(render-to-html (br))"), "br");
|
||||
|
||||
// Component rendering
|
||||
K.eval("(defcomp ~test-card (&key title) (div :class \"card\" (h2 title)))");
|
||||
assert("component render", K.eval("(render-to-html (~test-card :title \"Hello\"))"), "<div class=\"card\"><h2>Hello</h2></div>");
|
||||
|
||||
K.eval("(defcomp ~test-wrap (&key label) (div :class \"wrap\" (span label)))");
|
||||
assert("component nested", K.eval("(render-to-html (~test-wrap :label \"hi\"))"), "<div class=\"wrap\"><span>hi</span></div>");
|
||||
|
||||
// Core primitives
|
||||
assert("list length", K.eval("(list 1 2 3)").items.length, 3);
|
||||
assert("first", K.eval("(first (list 1 2 3))"), 1);
|
||||
assert("len", K.eval("(len (list 1 2 3))"), 3);
|
||||
assert("map", K.eval("(len (map (fn (x) (+ x 1)) (list 1 2 3)))"), 3);
|
||||
|
||||
// HTML tag registry
|
||||
assertNotError("HTML_TAGS defined", K.eval("(type-of HTML_TAGS)"));
|
||||
assert("is-html-tag? div", K.eval("(is-html-tag? \"div\")"), true);
|
||||
assert("is-html-tag? fake", K.eval("(is-html-tag? \"fake\")"), false);
|
||||
|
||||
// =====================================================================
|
||||
// Load web stack modules (same as sx-platform-2.js loadWebStack)
|
||||
// =====================================================================
|
||||
var fs = require("fs");
|
||||
var webStackFiles = [
|
||||
"shared/static/wasm/sx/render.sx",
|
||||
"shared/static/wasm/sx/core-signals.sx",
|
||||
"shared/static/wasm/sx/signals.sx",
|
||||
"shared/static/wasm/sx/deps.sx",
|
||||
"shared/static/wasm/sx/router.sx",
|
||||
"shared/static/wasm/sx/page-helpers.sx",
|
||||
"shared/static/wasm/sx/freeze.sx",
|
||||
"shared/static/wasm/sx/dom.sx",
|
||||
"shared/static/wasm/sx/browser.sx",
|
||||
"shared/static/wasm/sx/adapter-html.sx",
|
||||
"shared/static/wasm/sx/adapter-sx.sx",
|
||||
"shared/static/wasm/sx/adapter-dom.sx",
|
||||
"shared/static/wasm/sx/cssx.sx",
|
||||
"shared/static/wasm/sx/boot-helpers.sx",
|
||||
"shared/static/wasm/sx/hypersx.sx",
|
||||
"shared/static/wasm/sx/engine.sx",
|
||||
"shared/static/wasm/sx/orchestration.sx",
|
||||
"shared/static/wasm/sx/boot.sx",
|
||||
];
|
||||
var loadFails = [];
|
||||
var useBytecode = process.env.SX_TEST_BYTECODE === "1";
|
||||
if (K.beginModuleLoad) K.beginModuleLoad();
|
||||
for (var i = 0; i < webStackFiles.length; i++) {
|
||||
var loaded = false;
|
||||
if (useBytecode) {
|
||||
var bcPath = webStackFiles[i].replace(/\.sx$/, ".sxbc");
|
||||
try {
|
||||
var bcSrc = fs.readFileSync(bcPath, "utf8");
|
||||
global.__sxbcText = bcSrc;
|
||||
var bcResult = K.eval("(load-sxbc (first (parse (host-global \"__sxbcText\"))))");
|
||||
delete global.__sxbcText;
|
||||
if (typeof bcResult !== "string" || !bcResult.startsWith("Error")) {
|
||||
loaded = true;
|
||||
} else {
|
||||
loadFails.push(bcPath + " (sxbc): " + bcResult);
|
||||
}
|
||||
} catch(e) { delete global.__sxbcText; }
|
||||
}
|
||||
if (!loaded) {
|
||||
var src = fs.readFileSync(webStackFiles[i], "utf8");
|
||||
var r = K.load(src);
|
||||
if (typeof r === "string" && r.startsWith("Error")) {
|
||||
loadFails.push(webStackFiles[i] + ": " + r);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (K.endModuleLoad) K.endModuleLoad();
|
||||
if (loadFails.length > 0) {
|
||||
console.error("Module load failures:");
|
||||
loadFails.forEach(function(f) { console.error(" " + f); });
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Section 2: render-to-dom (requires working DOM stubs)
|
||||
// All DOM results are host objects — use host-get/dom-get-attr from SX
|
||||
// =====================================================================
|
||||
|
||||
// Basic DOM rendering
|
||||
assert("dom tagName",
|
||||
K.eval("(host-get (render-to-dom (div :class \"test\" \"hello\") (global-env) nil) \"tagName\")"),
|
||||
"div");
|
||||
assert("dom class attr",
|
||||
K.eval("(dom-get-attr (render-to-dom (div :class \"test\" \"hello\") (global-env) nil) \"class\")"),
|
||||
"test");
|
||||
assertIncludes("dom outerHTML",
|
||||
K.eval("(host-get (render-to-dom (div :class \"test\" \"hello\") (global-env) nil) \"outerHTML\")"),
|
||||
"hello");
|
||||
|
||||
// Nested DOM rendering
|
||||
assertIncludes("nested dom outerHTML",
|
||||
K.eval("(host-get (render-to-dom (div :class \"outer\" (span :id \"inner\" \"text\")) (global-env) nil) \"outerHTML\")"),
|
||||
"class=\"outer\"");
|
||||
|
||||
// =====================================================================
|
||||
// Section 3: Reactive rendering — with-island-scope + deref
|
||||
// This is the critical test for the hydration bug.
|
||||
// with-island-scope should NOT strip attributes.
|
||||
// =====================================================================
|
||||
|
||||
// 3a. with-island-scope should preserve static attributes
|
||||
assert("scoped static class",
|
||||
K.eval("(dom-get-attr (let ((d (list))) (with-island-scope (fn (x) (append! d x)) (fn () (render-to-dom (div :class \"scoped\" \"text\") (global-env) nil)))) \"class\")"),
|
||||
"scoped");
|
||||
|
||||
// 3b. Signal deref in text position should render initial value
|
||||
assertIncludes("signal text initial value",
|
||||
K.eval("(host-get (let ((s (signal 42)) (d (list))) (with-island-scope (fn (x) (append! d x)) (fn () (render-to-dom (div (deref s)) (global-env) nil)))) \"outerHTML\")"),
|
||||
"42");
|
||||
|
||||
// 3c. Signal deref in attribute position should set initial value
|
||||
assert("signal attr initial value",
|
||||
K.eval("(dom-get-attr (let ((s (signal \"active\")) (d (list))) (with-island-scope (fn (x) (append! d x)) (fn () (render-to-dom (div :class (deref s) \"content\") (global-env) nil)))) \"class\")"),
|
||||
"active");
|
||||
|
||||
// 3d. After signal update, reactive DOM should update
|
||||
// render-to-dom needs unevaluated expr (as in real browser boot from parsed source)
|
||||
K.eval("(define test-reactive-sig (signal \"before\"))");
|
||||
assert("reactive attr update",
|
||||
K.eval("(let ((d (list))) (let ((el (with-island-scope (fn (x) (append! d x)) (fn () (render-to-dom (quote (div :class (deref test-reactive-sig) \"content\")) (global-env) nil))))) (reset! test-reactive-sig \"after\") (dom-get-attr el \"class\")))"),
|
||||
"after");
|
||||
|
||||
// =====================================================================
|
||||
// Summary
|
||||
// =====================================================================
|
||||
console.log("WASM kernel tests: " + pass + " passed, " + fail + " failed");
|
||||
if (fail > 0) process.exit(1);
|
||||
'
|
||||
236
hosts/ocaml/browser/test_wasm_native.js
Normal file
236
hosts/ocaml/browser/test_wasm_native.js
Normal file
@@ -0,0 +1,236 @@
|
||||
#!/usr/bin/env node
|
||||
// test_wasm_native.js — Run WASM kernel tests in Node.js using the actual
|
||||
// WASM binary (not js_of_ocaml JS fallback). This tests the exact same
|
||||
// kernel that runs in the browser.
|
||||
//
|
||||
// Usage: node hosts/ocaml/browser/test_wasm_native.js
|
||||
// SX_TEST_BYTECODE=1 node hosts/ocaml/browser/test_wasm_native.js
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PROJECT_ROOT = path.resolve(__dirname, '../../..');
|
||||
const WASM_DIR = path.join(PROJECT_ROOT, 'shared/static/wasm');
|
||||
|
||||
// --- DOM stubs ---
|
||||
function makeElement(tag) {
|
||||
const el = {
|
||||
tagName: tag, _attrs: {}, _children: [], style: {},
|
||||
childNodes: [], children: [], textContent: '',
|
||||
nodeType: 1,
|
||||
setAttribute(k, v) { el._attrs[k] = String(v); },
|
||||
getAttribute(k) { return el._attrs[k] || null; },
|
||||
removeAttribute(k) { delete el._attrs[k]; },
|
||||
appendChild(c) { el._children.push(c); el.childNodes.push(c); el.children.push(c); return c; },
|
||||
insertBefore(c) { el._children.push(c); el.childNodes.push(c); el.children.push(c); return c; },
|
||||
removeChild(c) { return c; },
|
||||
replaceChild(n) { return n; },
|
||||
cloneNode() { return makeElement(tag); },
|
||||
addEventListener() {}, removeEventListener() {}, dispatchEvent() {},
|
||||
get innerHTML() {
|
||||
return el._children.map(c => {
|
||||
if (c._isText) return c.textContent || '';
|
||||
if (c._isComment) return '<!--' + (c.textContent || '') + '-->';
|
||||
return c.outerHTML || '';
|
||||
}).join('');
|
||||
},
|
||||
set innerHTML(v) { el._children = []; el.childNodes = []; el.children = []; },
|
||||
get outerHTML() {
|
||||
let s = '<' + tag;
|
||||
for (const k of Object.keys(el._attrs).sort()) s += ` ${k}="${el._attrs[k]}"`;
|
||||
s += '>';
|
||||
if (['br','hr','img','input','meta','link'].includes(tag)) return s;
|
||||
return s + el.innerHTML + '</' + tag + '>';
|
||||
},
|
||||
dataset: new Proxy({}, {
|
||||
get(_, k) { return el._attrs['data-' + k.replace(/[A-Z]/g, c => '-' + c.toLowerCase())]; },
|
||||
set(_, k, v) { el._attrs['data-' + k.replace(/[A-Z]/g, c => '-' + c.toLowerCase())] = v; return true; }
|
||||
}),
|
||||
querySelectorAll() { return []; },
|
||||
querySelector() { return null; },
|
||||
};
|
||||
return el;
|
||||
}
|
||||
|
||||
global.window = global;
|
||||
global.document = {
|
||||
createElement: makeElement,
|
||||
createDocumentFragment() { return makeElement('fragment'); },
|
||||
head: makeElement('head'), body: makeElement('body'),
|
||||
querySelector() { return null; }, querySelectorAll() { return []; },
|
||||
createTextNode(s) { return { _isText: true, textContent: String(s), nodeType: 3 }; },
|
||||
addEventListener() {},
|
||||
createComment(s) { return { _isComment: true, textContent: s || '', nodeType: 8 }; },
|
||||
getElementsByTagName() { return []; },
|
||||
};
|
||||
global.localStorage = { getItem() { return null; }, setItem() {}, removeItem() {} };
|
||||
global.CustomEvent = class { constructor(n, o) { this.type = n; this.detail = (o || {}).detail || {}; } };
|
||||
global.MutationObserver = class { observe() {} disconnect() {} };
|
||||
global.requestIdleCallback = fn => setTimeout(fn, 0);
|
||||
global.matchMedia = () => ({ matches: false });
|
||||
global.navigator = { serviceWorker: { register() { return Promise.resolve(); } } };
|
||||
global.location = { href: '', pathname: '/', hostname: 'localhost' };
|
||||
global.history = { pushState() {}, replaceState() {} };
|
||||
global.fetch = () => Promise.resolve({ ok: true, text() { return Promise.resolve(''); } });
|
||||
global.XMLHttpRequest = class { open() {} send() {} };
|
||||
|
||||
// --- Load WASM kernel ---
|
||||
async function main() {
|
||||
// The WASM loader sets globalThis.SxKernel after async init
|
||||
require(path.join(WASM_DIR, 'sx_browser.bc.wasm.js'));
|
||||
|
||||
// Poll for SxKernel (WASM init is async)
|
||||
const K = await new Promise((resolve, reject) => {
|
||||
let tries = 0;
|
||||
const poll = setInterval(() => {
|
||||
if (globalThis.SxKernel) { clearInterval(poll); resolve(globalThis.SxKernel); }
|
||||
else if (++tries > 200) { clearInterval(poll); reject(new Error('SxKernel not found after 10s')); }
|
||||
}, 50);
|
||||
});
|
||||
|
||||
console.log('WASM kernel loaded (native WASM, not JS fallback)');
|
||||
|
||||
// --- Register 8 FFI host primitives ---
|
||||
K.registerNative('host-global', args => {
|
||||
const name = args[0];
|
||||
return (name in globalThis) ? globalThis[name] : null;
|
||||
});
|
||||
K.registerNative('host-get', args => {
|
||||
const [obj, prop] = args;
|
||||
if (obj == null) return null;
|
||||
const v = obj[prop];
|
||||
return v === undefined ? null : v;
|
||||
});
|
||||
K.registerNative('host-set!', args => { if (args[0] != null) args[0][args[1]] = args[2]; return args[2]; });
|
||||
K.registerNative('host-call', args => {
|
||||
const [obj, method, ...rest] = args;
|
||||
if (obj == null || typeof obj[method] !== 'function') return null;
|
||||
const r = obj[method].apply(obj, rest);
|
||||
return r === undefined ? null : r;
|
||||
});
|
||||
K.registerNative('host-new', args => new (Function.prototype.bind.apply(args[0], [null, ...args.slice(1)])));
|
||||
K.registerNative('host-callback', args => function() { return K.callFn(args[0], Array.from(arguments)); });
|
||||
K.registerNative('host-typeof', args => typeof args[0]);
|
||||
K.registerNative('host-await', args => args[0]);
|
||||
|
||||
K.eval('(define SX_VERSION "test-wasm-1.0")');
|
||||
K.eval('(define SX_ENGINE "ocaml-vm-wasm-test")');
|
||||
K.eval('(define parse sx-parse)');
|
||||
K.eval('(define serialize sx-serialize)');
|
||||
|
||||
// --- Load web stack modules ---
|
||||
const useBytecode = process.env.SX_TEST_BYTECODE === '1';
|
||||
const sxDir = path.join(WASM_DIR, 'sx');
|
||||
const modules = [
|
||||
'render', 'core-signals', 'signals', 'deps', 'router', 'page-helpers', 'freeze',
|
||||
'bytecode', 'compiler', 'vm', 'dom', 'browser',
|
||||
'adapter-html', 'adapter-sx', 'adapter-dom',
|
||||
'cssx', 'boot-helpers', 'hypersx',
|
||||
'harness', 'harness-reactive', 'harness-web',
|
||||
'engine', 'orchestration', 'boot',
|
||||
];
|
||||
|
||||
if (K.beginModuleLoad) K.beginModuleLoad();
|
||||
for (const mod of modules) {
|
||||
let loaded = false;
|
||||
if (useBytecode) {
|
||||
try {
|
||||
const bcSrc = fs.readFileSync(path.join(sxDir, mod + '.sxbc'), 'utf8');
|
||||
global.__sxbcText = bcSrc;
|
||||
const r = K.eval('(load-sxbc (first (parse (host-global "__sxbcText"))))');
|
||||
delete global.__sxbcText;
|
||||
if (typeof r !== 'string' || !r.startsWith('Error')) { loaded = true; }
|
||||
} catch (e) { delete global.__sxbcText; }
|
||||
}
|
||||
if (!loaded) {
|
||||
const src = fs.readFileSync(path.join(sxDir, mod + '.sx'), 'utf8');
|
||||
K.load(src);
|
||||
}
|
||||
}
|
||||
if (K.endModuleLoad) K.endModuleLoad();
|
||||
|
||||
// --- Test runner ---
|
||||
let pass = 0, fail = 0;
|
||||
function assert(name, got, expected) {
|
||||
if (got === expected) { pass++; }
|
||||
else { fail++; console.error(`FAIL: ${name}\n got: ${JSON.stringify(got)}\n expected: ${JSON.stringify(expected)}`); }
|
||||
}
|
||||
function assertIncludes(name, got, substr) {
|
||||
if (typeof got === 'string' && got.includes(substr)) { pass++; }
|
||||
else { fail++; console.error(`FAIL: ${name}\n got: ${JSON.stringify(got)}\n expected to include: ${JSON.stringify(substr)}`); }
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
const SCOPED_TEST = '(dom-get-attr (let ((d (list))) (with-island-scope (fn (x) (append! d x)) (fn () (render-to-dom (div :class "scoped" "text") (global-env) nil)))) "class")';
|
||||
|
||||
// Basic
|
||||
assert('arithmetic', K.eval('(+ 1 2)'), 3);
|
||||
assert('div preserves keywords', K.eval('(inspect (div :class "test" "hello"))'), '(div :class "test" "hello")');
|
||||
assert('render div+class', K.eval('(render-to-html (div :class "card" "content"))'), '<div class="card">content</div>');
|
||||
|
||||
// DOM rendering
|
||||
assert('dom class attr',
|
||||
K.eval('(dom-get-attr (render-to-dom (div :class "test" "hello") (global-env) nil) "class")'),
|
||||
'test');
|
||||
|
||||
// Reactive: scoped static class
|
||||
assert('scoped static class',
|
||||
K.eval(SCOPED_TEST), 'scoped');
|
||||
|
||||
// Reactive: signal deref initial value in scope
|
||||
assert('signal attr initial value',
|
||||
K.eval('(dom-get-attr (let ((s (signal "active")) (d (list))) (with-island-scope (fn (x) (append! d x)) (fn () (render-to-dom (div :class (deref s) "content") (global-env) nil)))) "class")'),
|
||||
'active');
|
||||
|
||||
// Reactive: signal text in scope
|
||||
assertIncludes('signal text in scope',
|
||||
K.eval('(host-get (let ((s (signal 42)) (d (list))) (with-island-scope (fn (x) (append! d x)) (fn () (render-to-dom (div (deref s)) (global-env) nil)))) "outerHTML")'),
|
||||
'42');
|
||||
|
||||
// CRITICAL: define vs let closure with host objects + effect
|
||||
// This is the root cause of the hydration rendering bug.
|
||||
// A function defined with `define` that takes a host object (DOM element)
|
||||
// and uses `effect` to modify it — the effect body doesn't see the element.
|
||||
assert('define+effect+host-obj',
|
||||
K.eval('(do (define test-set-attr (fn (el name val) (effect (fn () (dom-set-attr el name val))))) (let ((el (dom-create-element "div" nil))) (test-set-attr el "class" "from-define") (dom-get-attr el "class")))'),
|
||||
'from-define');
|
||||
|
||||
// Same thing with let works (proves it's define-specific)
|
||||
assert('let+effect+host-obj',
|
||||
K.eval('(let ((test-set-attr (fn (el name val) (effect (fn () (dom-set-attr el name val)))))) (let ((el (dom-create-element "div" nil))) (test-set-attr el "class" "from-let") (dom-get-attr el "class")))'),
|
||||
'from-let');
|
||||
|
||||
// Reactive: signal update propagation
|
||||
// Note: render-to-dom needs the UNEVALUATED expression (as in real browser boot
|
||||
// where expressions come from parsing). Use quote to prevent eager eval of (deref s).
|
||||
K.eval('(define test-reactive-sig (signal "before"))');
|
||||
assert('reactive attr update',
|
||||
K.eval("(let ((d (list))) (let ((el (with-island-scope (fn (x) (append! d x)) (fn () (render-to-dom '(div :class (deref test-reactive-sig) \"content\") (global-env) nil))))) (reset! test-reactive-sig \"after\") (dom-get-attr el \"class\")))"),
|
||||
'after');
|
||||
|
||||
// =====================================================================
|
||||
// Section: Boot step bisection
|
||||
// Simulate boot steps to find which one breaks scoped rendering
|
||||
// =====================================================================
|
||||
if (process.env.SX_TEST_BOOT_BISECT === '1') {
|
||||
console.log('\n=== Boot step bisection ===');
|
||||
const bootSteps = [
|
||||
['init-css-tracking', '(init-css-tracking)'],
|
||||
['process-page-scripts', '(process-page-scripts)'],
|
||||
// process-sx-scripts needs <script type="text/sx"> in DOM — skip in Node
|
||||
];
|
||||
for (const [name, expr] of bootSteps) {
|
||||
const before = K.eval(SCOPED_TEST);
|
||||
K.eval(expr);
|
||||
const after = K.eval(SCOPED_TEST);
|
||||
console.log(` ${name}: before=${before} after=${after} ${before !== after ? '*** CHANGED ***' : 'ok'}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
console.log(`WASM native tests: ${pass} passed, ${fail} failed`);
|
||||
process.exit(fail > 0 ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch(e => { console.error('FATAL:', e.message); process.exit(1); });
|
||||
@@ -480,9 +480,9 @@ and step_sf_scope args env kont =
|
||||
and step_sf_provide args env kont =
|
||||
(let name = (trampoline ((eval_expr ((first (args))) (env)))) in let val' = (trampoline ((eval_expr ((nth (args) ((Number 1.0)))) (env)))) in let body = (prim_call "slice" [args; (Number 2.0)]) in (if sx_truthy ((empty_p (body))) then (make_cek_value (Nil) (env) (kont)) else (make_cek_state ((first (body))) (env) ((kont_push ((make_provide_frame (name) (val') ((rest (body))) (env))) (kont))))))
|
||||
|
||||
(* step-sf-context *)
|
||||
(* step-sf-context — check kont provide frames first, then fall back to scope_stacks *)
|
||||
and step_sf_context args env kont =
|
||||
(let name = (trampoline ((eval_expr ((first (args))) (env)))) in let default_val = (if sx_truthy ((prim_call ">=" [(len (args)); (Number 2.0)])) then (trampoline ((eval_expr ((nth (args) ((Number 1.0)))) (env)))) else Nil) in let frame = (kont_find_provide (kont) (name)) in (make_cek_value ((if sx_truthy ((is_nil (frame))) then default_val else (get (frame) ((String "value"))))) (env) (kont)))
|
||||
(let name = (trampoline ((eval_expr ((first (args))) (env)))) in let default_val = (if sx_truthy ((prim_call ">=" [(len (args)); (Number 2.0)])) then (trampoline ((eval_expr ((nth (args) ((Number 1.0)))) (env)))) else Nil) in let frame = (kont_find_provide (kont) (name)) in (if sx_truthy ((Bool (not (sx_truthy ((is_nil (frame))))))) then (make_cek_value ((get (frame) ((String "value")))) (env) (kont)) else (let scope_val = (sx_context (name) (Nil)) in (make_cek_value ((if sx_truthy ((is_nil (scope_val))) then default_val else scope_val)) (env) (kont)))))
|
||||
|
||||
(* step-sf-emit *)
|
||||
and step_sf_emit args env kont =
|
||||
|
||||
@@ -77,16 +77,24 @@ let () =
|
||||
|
||||
register "context" (fun args ->
|
||||
match args with
|
||||
| [String name] | [String name; _] ->
|
||||
| (String name) :: rest ->
|
||||
let stack = try Hashtbl.find scope_stacks name with Not_found -> [] in
|
||||
if !_scope_trace then
|
||||
_scope_log := Printf.sprintf "CTX %s depth=%d found=%b" name (List.length stack) (stack <> []) :: !_scope_log;
|
||||
(match stack, args with
|
||||
| v :: _, _ -> v
|
||||
| [], [_; default_val] -> default_val
|
||||
| [], _ -> Nil)
|
||||
(match stack with
|
||||
| v :: _ -> v
|
||||
| [] -> (match rest with default_val :: _ -> default_val | [] -> Nil))
|
||||
| _ -> Nil);
|
||||
|
||||
register "context-debug" (fun args ->
|
||||
match args with
|
||||
| [String name] ->
|
||||
let stack = try Hashtbl.find scope_stacks name with Not_found -> [] in
|
||||
let all_keys = Hashtbl.fold (fun k _ acc -> k :: acc) scope_stacks [] in
|
||||
String (Printf.sprintf "name=%s stack_len=%d all_keys=[%s]"
|
||||
name (List.length stack) (String.concat "," all_keys))
|
||||
| _ -> String "bad args");
|
||||
|
||||
(* --- Collect / collected / clear-collected! --- *)
|
||||
|
||||
register "collect!" (fun args ->
|
||||
|
||||
@@ -163,7 +163,7 @@
|
||||
:effects (mutation)
|
||||
(fn
|
||||
((s :as dict))
|
||||
(for-each (fn ((sub :as lambda)) (sub)) (signal-subscribers s))))
|
||||
(for-each (fn (sub) (cek-call sub nil)) (signal-subscribers s))))
|
||||
|
||||
(define
|
||||
dispose-computed
|
||||
|
||||
BIN
shared/static/wasm/sx_browser.bc.wasm.assets/sx-a0c22109.wasm
Normal file
BIN
shared/static/wasm/sx_browser.bc.wasm.assets/sx-a0c22109.wasm
Normal file
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -1792,7 +1792,7 @@
|
||||
blake2_js_for_wasm_create: blake2_js_for_wasm_create};
|
||||
}
|
||||
(globalThis))
|
||||
({"link":[["runtime-0db9b496",0],["prelude-d7e4b000",0],["stdlib-23ce0836",[]],["sx-6003da22",[2]],["jsoo_runtime-f96b44a8",[2]],["js_of_ocaml-651f6707",[2,4]],["dune__exe__Sx_browser-87cfa374",[2,3,5]],["std_exit-10fb8830",[2]],["start-80fdb768",0]],"generated":(b=>{var
|
||||
({"link":[["runtime-0db9b496",0],["prelude-d7e4b000",0],["stdlib-23ce0836",[]],["sx-a0c22109",[2]],["jsoo_runtime-f96b44a8",[2]],["js_of_ocaml-651f6707",[2,4]],["dune__exe__Sx_browser-8ae21d0a",[2,3,5]],["std_exit-10fb8830",[2]],["start-80fdb768",0]],"generated":(b=>{var
|
||||
c=b,a=b?.module?.export||b;return{"env":{"caml_ba_kind_of_typed_array":()=>{throw new
|
||||
Error("caml_ba_kind_of_typed_array not implemented")},"caml_exn_with_js_backtrace":()=>{throw new
|
||||
Error("caml_exn_with_js_backtrace not implemented")},"caml_int64_create_lo_mi_hi":()=>{throw new
|
||||
|
||||
@@ -163,7 +163,7 @@
|
||||
:effects (mutation)
|
||||
(fn
|
||||
((s :as dict))
|
||||
(for-each (fn ((sub :as lambda)) (sub)) (signal-subscribers s))))
|
||||
(for-each (fn (sub) (cek-call sub nil)) (signal-subscribers s))))
|
||||
|
||||
(define
|
||||
dispose-computed
|
||||
|
||||
Reference in New Issue
Block a user