Restore hyperscript work on stable site base (908f4f80)
Reset to last known-good state (908f4f80) where links, stepper, and
islands all work, then recovered all hyperscript implementation,
conformance tests, behavioral tests, Playwright specs, site sandbox,
IO-aware server loading, and upstream test suite from f271c88a.
Excludes runtime changes (VM resolve hook, VmSuspended browser handler,
sx_ref.ml guard recovery) that need careful re-integration.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -71,6 +71,11 @@ cp "$ROOT/shared/sx/templates/tw-layout.sx" "$DIST/sx/"
|
||||
cp "$ROOT/shared/sx/templates/tw-type.sx" "$DIST/sx/"
|
||||
cp "$ROOT/shared/sx/templates/tw.sx" "$DIST/sx/"
|
||||
|
||||
# 10. Hyperscript
|
||||
for f in tokenizer parser compiler runtime integration; do
|
||||
cp "$ROOT/lib/hyperscript/$f.sx" "$DIST/sx/hs-$f.sx"
|
||||
done
|
||||
|
||||
# Summary
|
||||
WASM_SIZE=$(du -sh "$DIST/sx_browser.bc.wasm.assets" | cut -f1)
|
||||
JS_SIZE=$(du -sh "$DIST/sx_browser.bc.js" | cut -f1)
|
||||
|
||||
@@ -80,7 +80,11 @@ const FILES = [
|
||||
'dom.sx', 'browser.sx', 'adapter-html.sx', 'adapter-sx.sx', 'adapter-dom.sx',
|
||||
'tw-layout.sx', 'tw-type.sx', 'tw.sx',
|
||||
'boot-helpers.sx', 'hypersx.sx', 'harness.sx', 'harness-reactive.sx',
|
||||
'harness-web.sx', 'engine.sx', 'orchestration.sx', 'boot.sx',
|
||||
'harness-web.sx', 'engine.sx', 'orchestration.sx',
|
||||
// Hyperscript modules — loaded on demand via transparent lazy loader
|
||||
'hs-tokenizer.sx', 'hs-parser.sx', 'hs-compiler.sx', 'hs-runtime.sx',
|
||||
'hs-integration.sx',
|
||||
'boot.sx',
|
||||
];
|
||||
|
||||
|
||||
@@ -339,6 +343,18 @@ function libKey(spec) {
|
||||
return spec.replace(/^\(/, '').replace(/\)$/, '');
|
||||
}
|
||||
|
||||
// Extract top-level (define name ...) symbols from a non-library file
|
||||
function extractDefines(source) {
|
||||
const names = [];
|
||||
const re = /^\(define\s+(\S+)/gm;
|
||||
let m;
|
||||
while ((m = re.exec(source)) !== null) {
|
||||
const name = m[1];
|
||||
if (name && !name.startsWith('(') && !name.startsWith(':')) names.push(name);
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
const manifest = {};
|
||||
let entryFile = null;
|
||||
|
||||
@@ -360,6 +376,18 @@ for (const file of FILES) {
|
||||
} else if (deps.length > 0) {
|
||||
// Entry point (no define-library, has imports)
|
||||
entryFile = { file: sxbcFile, deps: deps.map(libKey) };
|
||||
} else {
|
||||
// Non-library file (e.g. hyperscript modules) — extract top-level defines
|
||||
// as exports so the transparent lazy loader can resolve symbols to files.
|
||||
const defines = extractDefines(src);
|
||||
if (defines.length > 0) {
|
||||
const key = file.replace(/\.sx$/, '');
|
||||
manifest[key] = {
|
||||
file: sxbcFile,
|
||||
deps: [],
|
||||
exports: defines,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
229
hosts/ocaml/browser/test_hs_repeat.js
Normal file
229
hosts/ocaml/browser/test_hs_repeat.js
Normal file
@@ -0,0 +1,229 @@
|
||||
#!/usr/bin/env node
|
||||
// test_hs_repeat.js — Debug hyperscript repeat+wait continuation bug
|
||||
//
|
||||
// Runs the exact expression that fails in the browser:
|
||||
// on click repeat 3 times add .active to me then wait 300ms
|
||||
// then remove .active then wait 300ms end
|
||||
//
|
||||
// Uses the real WASM kernel with perform/resume_vm, NOT mock IO.
|
||||
// Waits are shortened to 1ms. All IO suspensions are logged.
|
||||
//
|
||||
// Usage: node hosts/ocaml/browser/test_hs_repeat.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 with class tracking ---
|
||||
function makeElement(tag) {
|
||||
const el = {
|
||||
tagName: tag, _attrs: {}, _children: [], _classes: new Set(),
|
||||
style: {}, childNodes: [], children: [], textContent: '',
|
||||
nodeType: 1,
|
||||
classList: {
|
||||
add(c) { el._classes.add(c); console.log(` [dom] classList.add("${c}") → {${[...el._classes]}}`); },
|
||||
remove(c) { el._classes.delete(c); console.log(` [dom] classList.remove("${c}") → {${[...el._classes]}}`); },
|
||||
contains(c) { return el._classes.has(c); },
|
||||
toggle(c) { if (el._classes.has(c)) el._classes.delete(c); else el._classes.add(c); },
|
||||
},
|
||||
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() {} };
|
||||
|
||||
async function main() {
|
||||
// Load WASM kernel
|
||||
require(path.join(WASM_DIR, 'sx_browser.bc.js'));
|
||||
const K = globalThis.SxKernel;
|
||||
if (!K) { console.error('FATAL: SxKernel not found'); process.exit(1); }
|
||||
console.log('WASM kernel loaded');
|
||||
|
||||
// Register FFI 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 => {
|
||||
const fn = args[0];
|
||||
return function() { return K.callFn(fn, Array.from(arguments)); };
|
||||
});
|
||||
K.registerNative('host-typeof', args => typeof args[0]);
|
||||
K.registerNative('host-await', args => args[0]);
|
||||
|
||||
K.eval('(define SX_VERSION "test-hs-1.0")');
|
||||
K.eval('(define SX_ENGINE "ocaml-vm-wasm-test")');
|
||||
K.eval('(define parse sx-parse)');
|
||||
K.eval('(define serialize sx-serialize)');
|
||||
|
||||
// Stub DOM primitives that HS runtime calls
|
||||
// dom-listen fires handler immediately (simulates the event)
|
||||
K.eval('(define dom-add-class (fn (el cls) (dict-set! (get el "classes") cls true) nil))');
|
||||
K.eval('(define dom-remove-class (fn (el cls) (dict-delete! (get el "classes") cls) nil))');
|
||||
K.eval('(define dom-has-class? (fn (el cls) (dict-has? (get el "classes") cls)))');
|
||||
K.eval('(define dom-listen (fn (target event-name handler) (handler {:type event-name :target target})))');
|
||||
|
||||
// Load hyperscript modules
|
||||
const hsFiles = [
|
||||
'lib/hyperscript/tokenizer.sx',
|
||||
'lib/hyperscript/parser.sx',
|
||||
'lib/hyperscript/compiler.sx',
|
||||
'lib/hyperscript/runtime.sx',
|
||||
];
|
||||
for (const f of hsFiles) {
|
||||
const src = fs.readFileSync(path.join(PROJECT_ROOT, f), 'utf8');
|
||||
const r = K.load(src);
|
||||
if (typeof r === 'string' && r.startsWith('Error')) {
|
||||
console.error(`Load failed: ${f}: ${r}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
console.log('Hyperscript modules loaded');
|
||||
|
||||
// Compile the expression
|
||||
const compiled = K.eval('(hs-to-sx-from-source "on click repeat 3 times add .active to me then wait 300ms then remove .active then wait 300ms end")');
|
||||
console.log('Compiled:', K.eval(`(inspect '${typeof compiled === 'string' ? compiled : '?'})`));
|
||||
// Actually get it as a string
|
||||
const compiledStr = K.eval('(inspect (hs-to-sx-from-source "on click repeat 3 times add .active to me then wait 300ms then remove .active then wait 300ms end"))');
|
||||
console.log('Compiled SX:', compiledStr);
|
||||
|
||||
// Create handler function (same as hs-handler does)
|
||||
K.eval('(define _test-me {:tag "button" :id "test" :classes {} :_hs-activated true})');
|
||||
|
||||
// Build the handler — wraps compiled SX in (fn (me) (let ((it nil) (event ...)) <sx>))
|
||||
const handlerSrc = K.eval('(inspect (hs-to-sx-from-source "on click repeat 3 times add .active to me then wait 300ms then remove .active then wait 300ms end"))');
|
||||
K.eval(`(define _test-handler
|
||||
(eval-expr
|
||||
(list 'fn '(me)
|
||||
(list 'let '((it nil) (event {:type "click" :target _test-me}))
|
||||
(hs-to-sx-from-source "on click repeat 3 times add .active to me then wait 300ms then remove .active then wait 300ms end")))))`);
|
||||
|
||||
console.log('\n=== Invoking handler (simulates click event) ===');
|
||||
console.log('Expected: 3 iterations × (add .active, wait 300, remove .active, wait 300)');
|
||||
console.log('Expected: 6 IO suspensions total\n');
|
||||
|
||||
// Call the handler — this will suspend on the first hs-wait (perform)
|
||||
let suspensionCount = 0;
|
||||
let result;
|
||||
try {
|
||||
result = K.callFn(K.eval('_test-handler'), [K.eval('_test-me')]);
|
||||
} catch(e) {
|
||||
console.error('Initial call error:', e.message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Drive async suspension chain with real timeouts (1ms instead of 300ms)
|
||||
function driveAsync(res) {
|
||||
return new Promise((resolve) => {
|
||||
function step(r) {
|
||||
if (!r || !r.suspended) {
|
||||
console.log(`\n=== Done. Total suspensions: ${suspensionCount} (expected: 6) ===`);
|
||||
console.log(`Result: ${r === null ? 'null' : typeof r === 'object' ? JSON.stringify(r) : r}`);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
suspensionCount++;
|
||||
const req = r.request;
|
||||
const items = req && (req.items || req);
|
||||
const op = items && items[0];
|
||||
const opName = typeof op === 'string' ? op : (op && op.name) || String(op);
|
||||
const arg = items && items[1];
|
||||
|
||||
console.log(`Suspension #${suspensionCount}: op=${opName} arg=${arg}`);
|
||||
|
||||
if (opName === 'io-sleep' || opName === 'wait') {
|
||||
// Resume after 1ms (not real 300ms)
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const resumed = r.resume(null);
|
||||
console.log(` Resumed: suspended=${resumed && resumed.suspended}, type=${typeof resumed}`);
|
||||
step(resumed);
|
||||
} catch(e) {
|
||||
console.error(` Resume error: ${e.message}`);
|
||||
resolve();
|
||||
}
|
||||
}, 1);
|
||||
} else {
|
||||
console.log(` Unhandled IO op: ${opName}`);
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
step(res);
|
||||
});
|
||||
}
|
||||
|
||||
await driveAsync(result);
|
||||
|
||||
// Check final element state
|
||||
const classes = K.eval('(get _test-me "classes")');
|
||||
console.log('\nFinal element classes:', JSON.stringify(classes));
|
||||
}
|
||||
|
||||
main().catch(e => { console.error('FATAL:', e.message); process.exit(1); });
|
||||
Reference in New Issue
Block a user