diff --git a/lib/hyperscript/compiler.sx b/lib/hyperscript/compiler.sx index 9e7eaac7..73d8ce50 100644 --- a/lib/hyperscript/compiler.sx +++ b/lib/hyperscript/compiler.sx @@ -653,6 +653,14 @@ (quote dom-add-class) (hs-to-sx (nth ast 2)) (nth ast 1))) + ((= head (quote multi-add-class)) + (let ((target (hs-to-sx (nth ast 1))) + (classes (rest (rest ast)))) + (cons (quote do) (map (fn (cls) (list (quote dom-add-class) target cls)) classes)))) + ((= head (quote multi-remove-class)) + (let ((target (hs-to-sx (nth ast 1))) + (classes (rest (rest ast)))) + (cons (quote do) (map (fn (cls) (list (quote dom-remove-class) target cls)) classes)))) ((= head (quote remove-class)) (list (quote dom-remove-class) diff --git a/lib/hyperscript/parser.sx b/lib/hyperscript/parser.sx index 1346f1e6..84ee38e1 100644 --- a/lib/hyperscript/parser.sx +++ b/lib/hyperscript/parser.sx @@ -666,10 +666,20 @@ (if (= (tp-type) "class") (let - ((cls (get (adv!) "value"))) + ((cls (get (adv!) "value")) + (extra-classes (list))) + ;; Collect additional class refs + (define collect-classes! + (fn () + (when (= (tp-type) "class") + (set! extra-classes (append extra-classes (list (get (adv!) "value")))) + (collect-classes!)))) + (collect-classes!) (let ((tgt (parse-tgt-kw "to" (list (quote me))))) - (list (quote add-class) cls tgt))) + (if (empty? extra-classes) + (list (quote add-class) cls tgt) + (cons (quote multi-add-class) (cons tgt (cons cls extra-classes)))))) nil))) (define parse-remove-cmd @@ -678,10 +688,19 @@ (if (= (tp-type) "class") (let - ((cls (get (adv!) "value"))) + ((cls (get (adv!) "value")) + (extra-classes (list))) + (define collect-classes! + (fn () + (when (= (tp-type) "class") + (set! extra-classes (append extra-classes (list (get (adv!) "value")))) + (collect-classes!)))) + (collect-classes!) (let ((tgt (parse-tgt-kw "from" (list (quote me))))) - (list (quote remove-class) cls tgt))) + (if (empty? extra-classes) + (list (quote remove-class) cls tgt) + (cons (quote multi-remove-class) (cons tgt (cons cls extra-classes)))))) nil))) (define parse-toggle-cmd diff --git a/shared/static/wasm/sx/hs-compiler.sx b/shared/static/wasm/sx/hs-compiler.sx index 9e7eaac7..73d8ce50 100644 --- a/shared/static/wasm/sx/hs-compiler.sx +++ b/shared/static/wasm/sx/hs-compiler.sx @@ -653,6 +653,14 @@ (quote dom-add-class) (hs-to-sx (nth ast 2)) (nth ast 1))) + ((= head (quote multi-add-class)) + (let ((target (hs-to-sx (nth ast 1))) + (classes (rest (rest ast)))) + (cons (quote do) (map (fn (cls) (list (quote dom-add-class) target cls)) classes)))) + ((= head (quote multi-remove-class)) + (let ((target (hs-to-sx (nth ast 1))) + (classes (rest (rest ast)))) + (cons (quote do) (map (fn (cls) (list (quote dom-remove-class) target cls)) classes)))) ((= head (quote remove-class)) (list (quote dom-remove-class) diff --git a/shared/static/wasm/sx/hs-parser.sx b/shared/static/wasm/sx/hs-parser.sx index 1346f1e6..84ee38e1 100644 --- a/shared/static/wasm/sx/hs-parser.sx +++ b/shared/static/wasm/sx/hs-parser.sx @@ -666,10 +666,20 @@ (if (= (tp-type) "class") (let - ((cls (get (adv!) "value"))) + ((cls (get (adv!) "value")) + (extra-classes (list))) + ;; Collect additional class refs + (define collect-classes! + (fn () + (when (= (tp-type) "class") + (set! extra-classes (append extra-classes (list (get (adv!) "value")))) + (collect-classes!)))) + (collect-classes!) (let ((tgt (parse-tgt-kw "to" (list (quote me))))) - (list (quote add-class) cls tgt))) + (if (empty? extra-classes) + (list (quote add-class) cls tgt) + (cons (quote multi-add-class) (cons tgt (cons cls extra-classes)))))) nil))) (define parse-remove-cmd @@ -678,10 +688,19 @@ (if (= (tp-type) "class") (let - ((cls (get (adv!) "value"))) + ((cls (get (adv!) "value")) + (extra-classes (list))) + (define collect-classes! + (fn () + (when (= (tp-type) "class") + (set! extra-classes (append extra-classes (list (get (adv!) "value")))) + (collect-classes!)))) + (collect-classes!) (let ((tgt (parse-tgt-kw "from" (list (quote me))))) - (list (quote remove-class) cls tgt))) + (if (empty? extra-classes) + (list (quote remove-class) cls tgt) + (cons (quote multi-remove-class) (cons tgt (cons cls extra-classes)))))) nil))) (define parse-toggle-cmd diff --git a/tests/playwright/hs-behavioral.spec.js b/tests/playwright/hs-behavioral.spec.js index ed3bd74d..7760dc65 100644 --- a/tests/playwright/hs-behavioral.spec.js +++ b/tests/playwright/hs-behavioral.spec.js @@ -198,16 +198,14 @@ test.describe('Hyperscript behavioral tests', () => { let result; try { result = await Promise.race([ - page.evaluate(idx => { + page.evaluate(async (idx) => { const K = window.SxKernel; - // Thorough cleanup: replace body to kill all event listeners const newBody = document.createElement('body'); document.documentElement.replaceChild(newBody, document.body); const thunk = K.eval(`(get (nth _test-registry ${idx}) "thunk")`); if (!thunk) return { p: false, e: 'no thunk' }; - // Capture errors — only from THIS test's execution let lastErr = null; const orig = console.error; console.error = function() { @@ -215,12 +213,52 @@ test.describe('Hyperscript behavioral tests', () => { if (m.startsWith('[sx]')) lastErr = m; orig.apply(console, arguments); }; + + // Drive async suspension chains (wait, fetch, etc.) + let pending = 0; + const oldDrive = window._driveAsync; + window._driveAsync = function driveAsync(result) { + if (!result || !result.suspended) return; + pending++; + const req = result.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]; + function doResume(val, delay) { + setTimeout(() => { + try { const r = result.resume(val); pending--; driveAsync(r); } + catch(e) { pending--; } + }, delay); + } + if (opName === 'io-sleep' || opName === 'wait') doResume(null, Math.min(typeof arg === 'number' ? arg : 0, 10)); + else if (opName === 'io-fetch') doResume({ok: true, text: ''}, 1); + else if (opName === 'io-settle') doResume(null, 5); + else if (opName === 'io-wait-event') doResume(null, 5); + else pending--; + }; + try { - K.callFn(thunk, []); + const r = K.callFn(thunk, []); + // If thunk itself suspended, drive it + if (r && r.suspended) window._driveAsync(r); + // Wait for all pending async chains to settle + if (pending > 0) { + await new Promise(resolve => { + let waited = 0; + const check = () => { + if (pending <= 0 || waited > 2000) resolve(); + else { waited += 10; setTimeout(check, 10); } + }; + setTimeout(check, 10); + }); + } console.error = orig; + window._driveAsync = oldDrive; return lastErr ? { p: false, e: lastErr.replace(/[\\"]/g, ' ').slice(0, 150) } : { p: true, e: null }; } catch(e) { console.error = orig; + window._driveAsync = oldDrive; return { p: false, e: (e.message || '').replace(/[\\"]/g, ' ').slice(0, 150) }; } }, i), @@ -285,9 +323,9 @@ test.describe('Hyperscript behavioral tests', () => { console.log(` [${info.count}x] ${e}`); } // Show samples of "bar" error specifically - const barSamples = results.filter(r => !r.p && (r.e||'').match(/exception: \w+ *$/)).slice(0, 10); + const barSamples = results.filter(r => !r.p && (r.e||'').includes('Assertion failed')).slice(0, 20); if (barSamples.length > 0) { - console.log(` "bar" error samples (${barSamples.length}):`); + console.log(` Assertion failures (${barSamples.length}):`); for (const s of barSamples) console.log(` ${s.s}/${s.n}`); }