diff --git a/hosts/ocaml/browser/test_wasm.sh b/hosts/ocaml/browser/test_wasm.sh
new file mode 100755
index 00000000..086bf4a1
--- /dev/null
+++ b/hosts/ocaml/browser/test_wasm.sh
@@ -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 "";
+ 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\"))"), "
content
");
+ assert("render h1+class", K.eval("(render-to-html (h1 :class \"title\" \"Hello\"))"), "Hello
");
+ assert("render a+href", K.eval("(render-to-html (a :href \"/about\" \"About\"))"), "About");
+ assert("render nested", K.eval("(render-to-html (div :class \"outer\" (span :class \"inner\" \"text\")))"), "text
");
+ 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\"))"), "Hello
");
+
+ K.eval("(defcomp ~test-wrap (&key label) (div :class \"wrap\" (span label)))");
+ assert("component nested", K.eval("(render-to-html (~test-wrap :label \"hi\"))"), "hi
");
+
+ // 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);
+'
diff --git a/hosts/ocaml/browser/test_wasm_native.js b/hosts/ocaml/browser/test_wasm_native.js
new file mode 100644
index 00000000..a8c84e98
--- /dev/null
+++ b/hosts/ocaml/browser/test_wasm_native.js
@@ -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 '';
+ 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"))'), 'content
');
+
+ // 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