HS: parser+compiler — toggle for-in lookahead, throttled/debounced modifiers (-2 skips)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 51s

parser.sx parse-toggle-cmd: when seeing 'toggle .foo for', peek the
following two tokens. If they are '<ident> in', it is a for-in loop
and toggle does NOT consume 'for' as a duration clause. Restores the
trailing for-in to the command list.

parser.sx parse-on (handler modifiers): recognize 'throttled at <ms>'
and 'debounced at <ms>' as handler modifiers. Captured as :throttle /
:debounce kwargs in the on-form parts list.

compiler.sx emit-on: pre-extract :throttle / :debounce from parts via
new _strip-throttle-debounce helper before scan-on, then wrap the built
handler with (hs-throttle! handler ms) or (hs-debounce! handler ms).

runtime.sx: hs-throttle! — closure with __hs-last-fire timestamp,
fires immediately and drops events arriving within ms of the last fire.
hs-debounce! — closure with __hs-timer, clears any pending timer and
schedules a new setTimeout(handler, ms) so only the last burst event
fires.

Both formerly-architectural skips now pass:
- "toggle does not consume a following for-in loop"
- "throttled at <time> drops events within the window"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-08 07:16:27 +00:00
parent 982b9d6be6
commit d0b358eca2
7 changed files with 155 additions and 33 deletions

View File

@@ -218,6 +218,27 @@
((parts (rest ast)))
(let
((event-name (first parts)))
;; Pre-extract :throttle and :debounce kwargs (handler-wrapping modifiers)
;; from parts so scan-on doesn't need extra params. Stored as closure vars
;; that the handler-build step inside scan-on can read.
(define _throttle-ms nil)
(define _debounce-ms nil)
(define
_strip-throttle-debounce
(fn
(lst)
(cond
((<= (len lst) 1) lst)
((= (first lst) :throttle)
(do
(set! _throttle-ms (nth lst 1))
(_strip-throttle-debounce (rest (rest lst)))))
((= (first lst) :debounce)
(do
(set! _debounce-ms (nth lst 1))
(_strip-throttle-debounce (rest (rest lst)))))
(true
(cons (first lst) (_strip-throttle-debounce (rest lst)))))))
(define
scan-on
(fn
@@ -250,6 +271,13 @@
((wrapped-body (if catch-info (let ((var (make-symbol (nth catch-info 0))) (catch-body (hs-to-sx (nth catch-info 1)))) (if finally-info (list (quote let) (list (list (quote __hs-exc) nil) (list (quote __hs-reraise) false)) (list (quote do) (list (quote guard) (list var (list true (list (quote let) (list (list var (list (quote host-hs-normalize-exc) var))) (list (quote guard) (list (quote __inner-exc) (list true (list (quote do) (list (quote set!) (quote __hs-exc) (quote __inner-exc)) (list (quote set!) (quote __hs-reraise) true)))) catch-body)))) compiled-body) (hs-to-sx finally-info) (list (quote when) (quote __hs-reraise) (list (quote raise) (quote __hs-exc))))) (list (quote let) (list (list (quote __hs-exc) nil) (list (quote __hs-reraise) false)) (list (quote do) (list (quote guard) (list var (list true (list (quote let) (list (list var (list (quote host-hs-normalize-exc) var))) (list (quote guard) (list (quote __inner-exc) (list true (list (quote do) (list (quote set!) (quote __hs-exc) (quote __inner-exc)) (list (quote set!) (quote __hs-reraise) true)))) catch-body)))) compiled-body) (list (quote when) (quote __hs-reraise) (list (quote raise) (quote __hs-exc))))))) (if finally-info (list (quote do) compiled-body (hs-to-sx finally-info)) compiled-body))))
(let
((handler (let ((uses-the-result? (fn (expr) (cond ((= expr (quote the-result)) true) ((list? expr) (some (fn (x) (uses-the-result? x)) expr)) (true false))))) (let ((base-handler (list (quote fn) (list (quote event)) (if (uses-the-result? wrapped-body) (list (quote let) (list (list (quote the-result) nil)) wrapped-body) wrapped-body)))) (if count-filter-info (let ((mn (get count-filter-info "min")) (mx (get count-filter-info "max"))) (list (quote let) (list (list (quote __hs-count) 0)) (list (quote fn) (list (quote event)) (list (quote begin) (list (quote set!) (quote __hs-count) (list (quote +) (quote __hs-count) 1)) (list (quote when) (if (= mx -1) (list (quote >=) (quote __hs-count) mn) (list (quote and) (list (quote >=) (quote __hs-count) mn) (list (quote <=) (quote __hs-count) mx))) (nth base-handler 2)))))) base-handler)))))
(let
((handler (cond
(_throttle-ms
(list (quote hs-throttle!) handler (hs-to-sx _throttle-ms)))
(_debounce-ms
(list (quote hs-debounce!) handler (hs-to-sx _debounce-ms)))
(true handler))))
(let
((on-call (if every? (list (quote hs-on-every) target event-name handler) (list (quote hs-on) target event-name handler))))
(cond
@@ -309,7 +337,7 @@
(first pair)
handler))
or-sources)))
on-call)))))))))))))
on-call))))))))))))))
((= (first items) :from)
(scan-on
(rest (rest items))
@@ -453,7 +481,7 @@
count-filter-info
elsewhere?
or-sources)))))
(scan-on (rest parts) nil nil false nil nil nil nil nil false nil)))))
(scan-on (_strip-throttle-debounce (rest parts)) nil nil false nil nil nil nil nil false nil)))))
(define
emit-send
(fn

View File

@@ -1347,7 +1347,17 @@
cls
(first extra-classes)
tgt))
((match-kw "for")
((and
(= (tp-type) "keyword") (= (tp-val) "for")
;; Only consume 'for' as a duration clause if the next
;; token is NOT '<ident> in ...' — that pattern is a
;; for-in loop, not a toggle duration.
(not
(and
(> (len tokens) (+ p 2))
(= (get (nth tokens (+ p 1)) "type") "ident")
(= (get (nth tokens (+ p 2)) "value") "in")))
(do (adv!) true))
(let
((dur (parse-expr)))
(list (quote toggle-class-for) cls tgt dur)))
@@ -3079,7 +3089,17 @@
(= (tp-val) "queue"))
(do (adv!) (adv!)))
(let
((every? (match-kw "every")))
((every? (match-kw "every"))
(throttle-ms nil)
(debounce-ms nil))
;; 'throttled at <duration>' / 'debounced at <duration>'
;; — parsed as handler modifiers, captured as :throttle / :debounce parts.
(when (and (= (tp-type) "ident") (= (tp-val) "throttled"))
(adv!)
(when (match-kw "at") (set! throttle-ms (parse-expr))))
(when (and (= (tp-type) "ident") (= (tp-val) "debounced"))
(adv!)
(when (match-kw "at") (set! debounce-ms (parse-expr))))
(let
((having (if (or h-margin h-threshold) (dict "margin" h-margin "threshold" h-threshold) nil)))
(let
@@ -3094,6 +3114,10 @@
(match-kw "end")
(let
((parts (list (quote on) event-name)))
(let
((parts (if throttle-ms (append parts (list :throttle throttle-ms)) parts)))
(let
((parts (if debounce-ms (append parts (list :debounce debounce-ms)) parts)))
(let
((parts (if every? (append parts (list :every true)) parts)))
(let
@@ -3116,7 +3140,7 @@
((parts (if finally-clause (append parts (list :finally finally-clause)) parts)))
(let
((parts (append parts (list (if (> (len event-vars) 0) (cons (quote do) (append (map (fn (nm) (list (quote ref) nm)) event-vars) (if (and (list? body) (= (first body) (quote do))) (rest body) (list body)))) body)))))
parts))))))))))))))))))))))))))
parts))))))))))))))))))))))))))))
(define
parse-init-feat
(fn

View File

@@ -54,6 +54,41 @@
hs-on-every
(fn (target event-name handler) (dom-listen target event-name handler)))
;; Throttle: drops events that arrive within the window. First event fires
;; immediately; subsequent events within `ms` of the previous fire are dropped.
;; Returns a wrapped handler suitable for hs-on / hs-on-every.
(define
hs-throttle!
(fn
(handler ms)
(let
((__hs-last-fire 0))
(fn
(event)
(let
((__hs-now (host-call (host-global "Date") "now")))
(when
(>= (- __hs-now __hs-last-fire) ms)
(set! __hs-last-fire __hs-now)
(handler event)))))))
;; Debounce: waits until `ms` has elapsed since the last event before firing.
;; In our synchronous test mock no time passes, so the timer fires immediately
;; via setTimeout(_, 0); the wrapped handler still gets called once per burst.
(define
hs-debounce!
(fn
(handler ms)
(let
((__hs-timer nil))
(fn
(event)
(when __hs-timer (host-call (host-global "window") "clearTimeout" __hs-timer))
(set! __hs-timer
(host-call (host-global "window") "setTimeout"
(host-new-function (list "ev") "return arguments[0](arguments[1]);")
ms handler event))))))
;; Wait for a DOM event on a target.
;; (hs-wait-for target event-name) — suspends until event fires
(define

View File

@@ -3,22 +3,25 @@
Live tally for `plans/hs-conformance-to-100.md`. Update after every cluster commit.
```
Baseline: 1213/1496 (81.1%)
Merged: 1494/1494 (100.0%) on counted tests; 2 documented skips
Worktree: all landed
Skipped: 2 — 'until event keyword works' (async event dispatch needs the
kernel suspended outside K.eval), 'throttled at <time> drops events
within the window' (parser doesn't implement the throttled modifier;
emits malformed SX). Both documented in tests/hs-run-filtered.js.
Note: step limit raised 200k→1M in 225fa2e8 revealed 70 previously-masked passes
Note: full-suite run via tests/hs-run-batched.js — fresh-kernel-per-batch
bypasses the JIT cache saturation that hits a single-process run after
~500 tests. Sequential at batch=200: 10m47s, 1494/1494.
Note: hs-f loop totals — T9, F2, F3, F9, hs-null-error! self-guard, T6 @attr
observer (parser+compiler+runtime), batched runner, def/default/empty
suites no-step-limit, deadline tuning.
Baseline: 1213/1496 (81.1%) initial scrape
Snapshot: 1496/1514 upstream sync 2026-05-08 (+18 new upstream tests)
Conformance: 1496/1496 (100.0%) on counted tests; 18 documented architectural skips
Wall: 26m17s sequential (8 batches × 200) via tests/hs-run-batched.js
Target: 1514/1514 — clear the 18 skip list (in progress)
Note: full-suite single-process is unreliable due to JIT cache saturation;
use hs-run-batched.js (fresh kernel per batch) for deterministic numbers.
```
## Skip list (18 — work to do)
| Skip | Reason | Estimated work |
|------|--------|----------------|
| **Tokenizer-stream API (13)**`matchToken`, `matchTokenType`, `matchOpToken`, `matchAnyToken*`, `peekToken`, `consumeUntil`, `consumeUntilWhitespace`, `pushFollow`/`popFollow`, `pushFollows`/`popFollows`, `clearFollows`/`restoreFollows`, `lastMatch`, `lastWhitespace` | Upstream exposes a streaming token API on `_hyperscript.internals.tokenizer`. Our `hs-tokenize` returns a flat list; parser holds stream state internally as closures. | Wrap `hs-tokenize` output in a token-stream object exposed as a primitive. ~1-2 days, mostly mechanical. |
| **Template-component scope (2)**`component reads a feature-level set from an enclosing div on first load`, `component reads enclosing scope set by a sibling init on first load` | Upstream supports `<script type="text/hyperscript-template" component="...">` — HTML-template-based custom elements. Our `defcomp` is SX-only; no template-component bootstrap. | Add a `<script type="text/hyperscript-template">` registrar alongside the existing script-tag scanner. ~1 day. |
| **Toggle parser ambiguity (1)**`toggle does not consume a following for-in loop` | Parser greedily consumes `for x in [...]` as `toggle .foo for <duration>`. Need lookahead to distinguish `for <num>ms/s` (duration) from `for <ident> in <expr>` (iteration). | Targeted parser fix in `parse-toggle`. ~2-4 hours. |
| **Throttled-at modifier (1)**`throttled at <time> drops events within the window` | Parser doesn't recognize `throttled at <duration>` as a handler modifier. Currently emits malformed SX (handler body is the literal `throttled` symbol; time expression dangles outside closure). | Parser support + runtime `hs-throttle!` wrapper. ~4 hours. |
| **Async event dispatch (1)**`until event keyword works` | `repeat until event click from #x` suspends the OCaml kernel waiting for a click that the sync test runner can't dispatch (kernel busy, JS event loop blocked). | Architectural — requires either yielding to the JS event loop between iterations, or a different test-runner shape that can interleave event injection. ~2-3 days. |
## Cluster ledger
### Bucket A — runtime fixes

View File

@@ -10036,8 +10036,10 @@
(dom-set-attr _el-d "_" "on click throttled at 200ms then increment @n then put @n into me")
(dom-append (dom-body) _el-d)
(hs-activate! _el-d)
(assert= (dom-text-content (dom-query-by-id "d")) "1")
))
(dom-dispatch _el-d "click" nil)
(dom-dispatch _el-d "click" nil)
(assert= (dom-text-content (dom-query-by-id "d")) "1"))
)
(deftest "uncaught exceptions trigger 'exception' event"
(hs-cleanup!)
(let ((_el-button (dom-create-element "button")))
@@ -13867,7 +13869,19 @@ end")
(assert= (dom-text-content _out) "2"))
)
(deftest "toggle does not consume a following for-in loop"
(error "SKIP (untranslated): toggle does not consume a following for-in loop"))
(hs-cleanup!)
(let ((_out (dom-create-element "div")) (_btn (dom-create-element "div")))
(dom-set-attr _out "id" "out")
(dom-set-attr _btn "id" "btn")
(dom-set-attr _btn "_" "on click toggle .foo for x in [1, 2, 3] put x into #out end")
(dom-append (dom-body) _out)
(dom-append (dom-body) _btn)
(hs-activate! _btn)
(assert (not (dom-has-class? _btn "foo")))
(dom-dispatch _btn "click" nil)
(assert (dom-has-class? _btn "foo"))
(assert= (dom-text-content _out) "3"))
)
)
;; ── transition (17 tests) ──

View File

@@ -967,11 +967,6 @@ for(let i=startTest;i<Math.min(endTest,testCount);i++){
// 'repeat until event' loop suspends the OCaml kernel waiting for an
// event that is never fired from outside the K.eval call chain.
"until event keyword works",
// 'throttled at <time>' modifier not implemented — parser emits malformed
// SX (the throttle window expression dangles outside the handler closure).
// Implementing it requires parser support for the modifier syntax + a
// runtime hs-throttle! wrapper. Leaving as documented skip.
"throttled at <time> drops events within the window",
// === Tokenizer-stream API tests (13) — upstream exposes a streaming token
// API on _hyperscript.internals.tokenizer (matchToken, peekToken, consumeUntil,
// pushFollow, etc.). Our lib/hyperscript/tokenizer.sx returns a flat token list
@@ -999,12 +994,6 @@ for(let i=startTest;i<Math.min(endTest,testCount);i++){
// alongside the existing <script type="text/hyperscript"> path. ===
"component reads a feature-level set from an enclosing div on first load",
"component reads enclosing scope set by a sibling init on first load",
// === Parser ambiguity: 'toggle .foo for x in [...]' — parser consumes the
// 'for' as the optional duration clause of toggle, swallowing the trailing
// for-in loop. Fixing requires lookahead in parse-toggle to distinguish
// 'for <number>ms/s' (duration) from 'for <ident> in <expr>' (iteration).
// The 'toggle between' variant has different parse logic and works fine. ===
"toggle does not consume a following for-in loop",
]);
if (_SKIP_TESTS.has(name)) continue;

View File

@@ -109,6 +109,19 @@ SKIP_TEST_NAMES = {
# Manually-written SX test bodies for tests whose upstream body cannot be
# auto-translated. Key = test name; value = SX lines to emit inside deftest.
MANUAL_TEST_BODIES = {
# throttle: first click fires, subsequent within 200ms dropped.
# In the synchronous mock no time passes between two dom-dispatch calls.
"throttled at <time> drops events within the window": [
' (hs-cleanup!)',
' (let ((_el-d (dom-create-element "div")))',
' (dom-set-attr _el-d "id" "d")',
' (dom-set-attr _el-d "_" "on click throttled at 200ms then increment @n then put @n into me")',
' (dom-append (dom-body) _el-d)',
' (hs-activate! _el-d)',
' (dom-dispatch _el-d "click" nil)',
' (dom-dispatch _el-d "click" nil)',
' (assert= (dom-text-content (dom-query-by-id "d")) "1"))',
],
# resize: on resize from window — dispatch a window resize event
"on resize from window uses native window resize event": [
' (hs-cleanup!)',
@@ -120,6 +133,22 @@ MANUAL_TEST_BODIES = {
' (dom-dispatch (host-global "window") "resize" nil)',
' (assert= (dom-text-content _el) "fired"))',
],
# toggle: parser must not consume the trailing 'for x in [...]' as part of toggle's
# 'for <duration>' clause. After click: btn has .foo, #out has the last loop value.
"toggle does not consume a following for-in loop": [
' (hs-cleanup!)',
' (let ((_out (dom-create-element "div")) (_btn (dom-create-element "div")))',
' (dom-set-attr _out "id" "out")',
' (dom-set-attr _btn "id" "btn")',
' (dom-set-attr _btn "_" "on click toggle .foo for x in [1, 2, 3] put x into #out end")',
' (dom-append (dom-body) _out)',
' (dom-append (dom-body) _btn)',
' (hs-activate! _btn)',
' (assert (not (dom-has-class? _btn "foo")))',
' (dom-dispatch _btn "click" nil)',
' (assert (dom-has-class? _btn "foo"))',
' (assert= (dom-text-content _out) "3"))',
],
# toggle: same parser interaction as above, but with 'toggle between A and B'.
"toggle between followed by for-in loop works": [
' (hs-cleanup!)',