22 JIT closure scoping tests covering:
- Basic closure var in map callback + context switch
- Signal + letrec + map (stepper pattern)
- Nested closures (inner lambda sees outer let var)
- Mutual recursion in letrec (is-even/is-odd)
- set! mutation of closure var after JIT compilation
- defisland with signal + letrec + map
- Deep nesting (for-each inside map inside letrec inside let)
All test the critical invariant: JIT-compiled lambdas must use
their closure's vm_env_ref, not the caller's globals.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Tests the exact pattern that broke the home stepper: a component
with letrec bindings referenced inside a map callback. The JIT
compiles the callback with closure vars merged into vm_env_ref.
Subsequent renders must use that env, not the caller's globals.
7 tests covering:
- letrec closure var in map callback (fmt function)
- Render, unrelated render, re-render (env not polluted)
- Signal + letrec + map (the stepper pattern)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When the VM called a JIT-compiled lambda, it passed vm.globals
(the caller's global env) instead of cl.vm_env_ref (the closure's
captured env that was merged at compile time). Closure-captured
variables like code-tokens from island let/letrec scopes were
invisible at runtime, causing "Undefined symbol" errors that
cascaded to disable render-to-html globally.
Fix: call_closure uses cl.vm_env_ref at both call sites (cached
bytecode and fresh compilation).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
rebuild-preview had one extra close paren that closed the outer
(when container) prematurely, pushing do-back and build-code-dom
out of the letrec scope. Result: "Undefined symbol: build-code-dom".
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The click buffer (capture + stopPropagation + replay) caused more
harm than good: synchronous XHR blocks the main thread during kernel
load, so there's no window where clicks can be captured. The buffer
was eating clicks after hydration due to property name mismatches.
Replace with pure CSS: buttons/links/[role=button] inside islands
get cursor:pointer from SSR. No JS needed, works immediately.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Buttons clicked before hydration get a subtle pulse animation
(sx-pending class) showing the click was captured. The animation
is removed when the click is replayed after hydration, or cleared
on boot completion as a fallback.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The event buffer script checked _sxBoundislandHydrated (camelCase)
but mark-processed! sets _sxBoundisland-hydrated (with hyphen).
The mismatch meant stopPropagation() fired on EVERY click, killing
all island button handlers.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The parser was reporting "Unexpected char: )" with no position info.
Added line number, column, and byte position to all parse errors.
Root cause: bind-sse-swap had one extra close paren that naive paren
counting missed because a "(" exists inside a string literal on L1074
(starts-with? trimmed "("). Parse-aware counting (skipping strings
and comments) correctly identified the imbalance.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- ocaml_sync.py: escape newlines in eval/load_source to prevent
protocol desync (bridge crashed on any multi-line SX)
- Stepper: do-back uses rebuild-preview (O(1) render) instead of
replaying all steps. Hydration effect same. Cookie save on button
click only.
- dom.sx: remove duplicate dom-listen (was shadowing the one at
line 351 that adapter-dom.sx's dom-on wraps)
- orchestration.sx: fix bind-sse-swap close paren count
- safe_eq: Dict equality via __host_handle for DOM node identity
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
adapter-dom.sx defines dom-on as a wrapper around dom-listen (adds
post-render hooks). But dom-listen was never defined — my earlier
dom-on in dom.sx was overwritten by the adapter's version. Rename
to dom-listen so the adapter's dom-on can call it.
This fixes click handlers not firing on island buttons (stepper,
stopwatch, counter, etc.).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Platform:
- sx-platform.js: extract ?v= query from script tag URL, append to
all .sx file XHR requests. Prevents stale cached .sx files.
Stepper performance:
- do-back: use rebuild-preview (pure SX→DOM render) instead of
replaying every do-step from 0. O(1) instead of O(n).
- Hydration effect: same rebuild-preview instead of step replay.
- Cookie save moved from do-step to button on-click handlers only.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Clicks on island elements before hydration completes are captured
and replayed after boot finishes:
- shell.sx: inline script (capture phase) buffers clicks on
[data-sx-island] elements that aren't hydrated yet into window._sxQ
- boot.sx: after hydration + process-elements, replays buffered clicks
by calling target.click() on elements still connected to the DOM
This makes SSR islands feel interactive immediately — the user can
click a button while the SX kernel is still loading/hydrating, and
the action fires as soon as the handler is wired up.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Server-side create-text-node was returning Nil, causing imperative
text nodes (stopwatch "Start"/"0.0s", imperative counter "0") to
render as empty in SSR HTML. Now returns the text as a String value,
which render-to-html handles via escape-html.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add wasm_hash (MD5 of sx_browser.bc.js) to shell template
- Script tags: /wasm/sx_browser.bc.js?v={hash}, /wasm/sx-platform.js?v={hash}
- Pass wasm_hash through helpers.py and ocaml_bridge.py
- Fix missing close paren in bind-sse-swap (broke SX parsing)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The DOM adapter treated all Dict values as empty (create-fragment).
But DOM nodes (text nodes, elements) from create-text-node/host-call
are wrapped as Dict { __host_handle: N } by js_of_ocaml. Now checks
for __host_handle and passes them through as DOM nodes.
Fixes stopwatch button text ("Start") and timer display ("0.0s")
which were missing because create-text-node results were discarded.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Navigation pipeline now works end-to-end:
- outerHTML swap uses dom-replace-child instead of morph-node (morph has
a CEK continuation issue with nested for-each that needs separate fix)
- swap-dom-nodes returns the new element for outerHTML so post-swap
hydrates the correct (new) DOM, not the detached old element
- sx-render uses marker mode: islands rendered as empty span[data-sx-island]
markers, hydrated by post-swap. Prevents duplicate content from island
body expansion + SX response nav rows.
- dispose-island (singular) called on old island before morph, not just
dispose-islands-in (which only disposes sub-islands)
OCaml runtime:
- safe_eq: Dict equality checks __host_handle for DOM node identity
(js_to_value creates new Dict wrappers per call, breaking physical ==)
- contains?: same host handle check
- to_string: trampoline thunks (fixes <thunk> display)
- as_number: trampoline thunks (fixes arithmetic on leaked thunks)
DOM platform:
- dom-remove, dom-attr-list (name/value pairs), dom-child-list (SX list),
dom-is-active-element?, dom-is-input-element?, dom-is-child-of?, dom-on
All 5 reactive-nav Playwright tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The opam base image has dune in the switch but not on PATH.
RUN eval $(opam env) doesn't persist across layers. Install dune
explicitly and set PATH so dune is available in build steps.
Also fix run-tests.sh to respect QUICK env var from caller
(was being overwritten to false).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Missed during spec/lib split — CI image copied spec/ and web/
but not lib/ (compiler, freeze, vm, etc.).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Missed during spec/lib split — the OCaml bridge test loaded
freeze.sx from the old spec/ path.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- web/orchestration.sx, web/signals.sx: dom-listen → dom-on (trampoline
wrapper that resolves TCO thunks from Lambda event handlers)
- .gitea/: CI workflow and Dockerfile for automated test runs
- tests/playwright/stepper.spec.js: stepper widget smoke test
- Remove stale artdag .pyc file
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three issues in the stepper island's client-side rendering:
1. do-step used eval-expr with empty env for ~cssx/tw spreads — component
not found, result leaked as [object Object]. Fixed: call ~cssx/tw
directly (in scope from island env) with trampoline.
2. steps-to-preview excluded spreads — SSR preview had no styling.
Fixed: include spreads in the tree so both SSR and client render
with CSSX classes.
3. build-children used named let (let loop ...) which produces
unresolved Thunks in render mode due to the named-let compiler
desugaring interacting with the render/eval boundary. Fixed:
rewrote as plain recursive function bc-loop avoiding named let.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
spec/ now contains only the language definition (5 files):
evaluator.sx, parser.sx, primitives.sx, render.sx, special-forms.sx
lib/ contains code written IN the language (8 files):
stdlib.sx, types.sx, freeze.sx, content.sx,
bytecode.sx, compiler.sx, vm.sx, callcc.sx
Test files follow source: spec/tests/ for core language tests,
lib/tests/ for library tests (continuations, freeze, types, vm).
Updated all consumers:
- JS/Python/OCaml bootstrappers: added lib/ to source search paths
- OCaml bridge: spec_dir for parser/render, lib_dir for compiler/freeze
- JS test runner: scans spec/tests/ (always) + lib/tests/ (--full)
- OCaml test runner: scans spec/tests/, lib tests via explicit request
- Docker dev mounts: added ./lib:/app/lib:ro
Tests: 1041 JS standard, 1322 JS full, 1101 OCaml — all pass
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Regression tests for the silent failure where callLambda returns a
Thunk (TCO) that must be trampolined for side effects to execute.
Without trampoline, event handlers (swap!, reset!) silently did nothing.
5 tests covering: single mutation, event arg passing, multi-statement
body, repeated accumulation, and nested lambda calls — all through
the (trampoline (call-lambda handler args)) pattern that dom-on uses.
Tests: 1322 JS (full), 1114 OCaml
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three bugs broke island SSR rendering of the home stepper widget:
1. Inline VM opcodes (OP_ADD..OP_DEC) broke JIT-compiled functions.
The compiler emitted single-byte opcodes for first/rest/len/= etc.
that produced wrong results in complex recursive code (sx-parse
returned nil, split-tag produced 1 step instead of 16). Reverted
compiler to use CALL_PRIM for all primitives. VM opcode handlers
kept for future use.
2. Named let (let loop ((x init)) body) had no compiler support —
silently produced broken bytecode. Added desugaring to letrec.
3. URL-encoded cookie values not decoded server-side. Client set-cookie
uses encodeURIComponent but Werkzeug doesn't decode cookie values.
Added unquote() in bridge cookie injection.
Also: call-lambda used eval_expr which copies Dict values (signals),
breaking mutations through aser lambda calls. Switched to cek_call.
Also: stepper preview now includes ~cssx/tw spreads for SSR styling.
Tests: 1317 JS, 1114 OCaml, 26 integration (2 pre-existing failures)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
16 new opcodes (160-175) bypass the CALL_PRIM hashtable lookup for
the most frequently called primitives:
Arithmetic: OP_ADD, OP_SUB, OP_MUL, OP_DIV, OP_INC, OP_DEC, OP_NEG
Comparison: OP_EQ, OP_LT, OP_GT, OP_NOT
Collection: OP_LEN, OP_FIRST, OP_REST, OP_NTH, OP_CONS
The compiler (compiler.sx) recognizes these names at compile time and
emits the inline opcode instead of CALL_PRIM. The opcode is self-
contained — no constant pool index, no argc byte. Each primitive is
a single byte in the bytecode stream.
Implementation in all three VMs:
- OCaml (sx_vm.ml): direct pattern match, no allocation
- SX spec (vm.sx): delegates to existing primitives
- JS (transpiled): same as SX spec
66 new tests in spec/tests/vm-inline.sx covering arithmetic, comparison,
collection ops, composition, and edge cases.
Tests: 1314 JS (full), 1114 OCaml, 32 Playwright
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
dom-on wraps Lambda event handlers in JS functions that call callLambda.
callLambda returns a Thunk (TCO), but the wrapper never trampolined it,
so the handler body (swap!, set!, etc.) never executed. Buttons rendered
but clicks had no effect.
Fix: wrap callLambda result in trampoline() so thunks resolve and
side effects (signal mutations, DOM updates) execute.
Also use call-lambda instead of direct invocation for Lambda objects
(Lambda is a plain JS object, not callable as a function).
All 100 Playwright tests pass:
- 6 isomorphic SSR
- 5 reactive navigation (cross-demo)
- 61 geography page loads
- 7 handler response rendering
- 21 demo interaction + health checks
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Reactive island tests (14): counter, temperature, stopwatch, input-binding,
dynamic-class, reactive-list, stores, refs, portal, imperative,
error-boundary, event-bridge, transition, resource
Marshes tests (5): hypermedia-feeds, on-settle, server-signals,
signal-triggers, view-transform
Health checks (2): no JS errors on reactive or marshes pages
Known failures: island signal reactivity broken on first page load
(buttons render but on-click handlers don't attach). Regression from
commits 2d87417/3ae49b6/13ba5ee — needs investigation.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
promise-delayed is a browser-only primitive used by the resource island
demo. The SSR renderer needs it as a stub to avoid "Undefined symbol"
errors during render-to-html JIT compilation.
The stub returns the value argument (skipping the delay), so SSR renders
the resolved state immediately.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When handler bodies use (let ((rows (map ...))) (<> rows)), the let
binding evaluates the map via CEK, which converts :class keywords to
"class" strings. The aser fragment serializer then outputs "class" as
text content instead of :class as an HTML attribute.
Fix: add aser-reserialize function that detects string pairs in
evaluated element lists where the first string matches known HTML
attribute names (class, id, sx-*, data-*, style, href, src, type,
name, value, etc.) and restores them as :keyword syntax.
All 7 handler response tests now pass:
- bulk-update, delete-row, click-to-load, active-search
- form-submission, edit-row, tabs
Total Playwright: 79/79
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the manual jit_allowlist (StringSet of ~40 function names) with
universal lazy compilation. Every named lambda gets one compile attempt
on first call; failures are sentineled and never retried.
Compiler internals are still pre-compiled at startup (bootstrapping the
JIT itself), but everything else compiles lazily — no manual curation.
Remove jit-allow command (no longer needed). Remove StringSet module.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
evaluator.sx defines *custom-special-forms* and register-special-form!
which shadow the host's native bindings when loaded at runtime. The
native bindings route to Sx_ref.custom_special_forms (the dict the CEK
evaluator checks), but the SX-level defines create a separate dict.
Fix: rebind_host_extensions runs after every load command, re-asserting
the native register-special-form! and *custom-special-forms* bindings.
Add regression test: custom form registered before evaluator.sx load
survives and remains callable via CEK dispatch afterward.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The transpiler treated any list with a non-symbol head as a data list,
emitting [head, args] as a JS array literal. When head is a sub-expression
(another call), it should emit (head)(args) — a function call.
This fixes the custom special forms dispatch in transpiled code:
Before: [get(_customSpecialForms, name), args, env] (array — broken)
After: (get(_customSpecialForms, name))(args, env) (call — correct)
Also fixes IIFE patterns: ((fn (x) body) arg) now emits
(function(x) { ... })(arg) instead of [function(x){...}, arg]
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Handler responses from defhandler (bulk-update, delete-row, etc.) render
"class" as text content instead of HTML attributes. The SX wire format
has "class" "value" (two strings) where :class "value" (keyword + string)
is needed. Tests check for 'classpx' in text content to detect the bug.
3 tests currently fail — will pass once handler aser keyword fix lands.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
serialize_value was falling through to "nil" for SxExpr and Spread values.
Now SxExpr passes through as raw SX text, Spread serializes as make-spread.
The aser command's result handler now joins a List of SxExprs as a
space-separated fragment (from map/filter producing multiple SxExprs).
Investigation ongoing: handler aser responses still have "class" strings
where :class keywords should be — the component expansion path in aser
loses keyword types during CEK evaluation of component bodies.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When morphing DOM after server fetch, the morph engine reuses elements
with the same tag. If old element was island A and new is island B,
syncAttrs updates data-sx-island but the JS property _sxBoundisland-hydrated
persists on the reused element. sx-hydrate-islands then skips it.
Fix: in morphNode, when data-sx-island attribute changes between old and
new elements, dispose the old island's signals and clear the hydration
flag so the new island gets properly hydrated.
New Playwright tests:
- counter → temperature navigation: temperature signals work
- temperature → counter navigation: counter signals work
- Direct load verification for both islands
- No JS errors during navigation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
primitive? in make_server_env was checking env bindings only (NativeFn),
missing all 132 primitives in the Sx_primitives hashtable. Now checks
both primitives table and env. get-primitive similarly fixed.
replace primitive now coerces SxExpr/Thunk/RawHTML/etc to strings instead
of crashing with "replace: 3 string args" — fixes aser JIT DISABLED.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Route all rendering through OCaml bridge — render_to_html no longer uses
Python async_eval. Fix register_components to parse &key params and &rest
children from defcomp forms. Remove all dead sx_ref.py imports.
Epoch protocol (prevents pipe desync):
- Every command prefixed with (epoch N), all responses tagged with epoch
- Both sides discard stale-epoch messages — desync structurally impossible
- OCaml main loop discards stale io-responses between commands
Consolidate scope primitives into sx_scope.ml:
- Single source of truth for scope-push!/pop!/peek, collect!/collected,
emit!/emitted, context, and 12 other scope operations
- Removes duplicate registrations from sx_server.ml (including bugs where
scope-emit! and clear-collected! were registered twice with different impls)
- Bind scope prims into env so JIT VM finds them via OP_GLOBAL_GET
JIT VM fixes:
- Trampoline thunks before passing args to CALL_PRIM
- as_list resolves thunks via _sx_trampoline_fn
- len handles all value types (Bool, Number, RawHTML, SxExpr, Spread, etc.)
Other fixes:
- ~cssx/tw signature: (tokens) → (&key tokens) to match callers
- Minimal Python evaluator in html.py for sync sx() Jinja function
- Python scope primitive stubs (thread-local) for non-OCaml paths
- Reader macro resolution via OcamlSync instead of sx_ref.py
Tests: 1114 OCaml, 1078 JS, 35 Python regression, 6/6 Playwright SSR
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
jinja_bridge.py was creating Component objects for both defcomp AND
defisland forms. Islands need Island objects so the serializer emits
defisland (not defcomp) in the client component bundle. Without this,
client-side islands don't get data-sx-island attributes, hydration
fails, and all reactive signals (colour cycling, stepper) stop working.
Add Playwright test: islands hydrate, stepper buttons update count,
reactive colour cycling works on click.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Tests that all 8 web definition forms (defhandler, defquery, defaction,
defpage, defrelation, defstyle, deftype, defeffect) are registered and
callable via the OCaml kernel. Catches the evaluator.sx env-shadowing
bug where loading evaluator.sx creates a new *custom-special-forms*
dict that shadows the native one.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Removes the 5993-line bootstrapped Python evaluator (sx_ref.py) and all
code that depended on it exclusively. Both bootstrappers (JS + OCaml)
now use a new synchronous OCaml bridge (ocaml_sync.py) to run the
transpiler. JS build produces identical output; OCaml bootstrap produces
byte-identical sx_ref.ml.
Key changes:
- New shared/sx/ocaml_sync.py: sync subprocess bridge to sx_server.exe
- hosts/javascript/bootstrap.py: serialize defines → temp file → OCaml eval
- hosts/ocaml/bootstrap.py: same pattern for OCaml transpiler
- shared/sx/{html,async_eval,resolver,jinja_bridge,handlers,pages,deps,helpers}:
stub or remove sx_ref imports; runtime uses OCaml bridge (SX_USE_OCAML=1)
- sx/sxc/pages: parse defpage/defhandler from AST instead of Python eval
- hosts/ocaml/lib/sx_primitives.ml: append handles non-list 2nd arg per spec
- Deleted: sx_ref.py, async_eval_ref.py, 6 Python test runners, misc ref/ files
Test results: JS 1078/1078, OCaml 1114/1114.
sx_docs SSR has pre-existing rendering issues to investigate separately.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
OCaml kernel is the evaluator. Python host tests via sx_ref.py are
no longer relevant to the deploy gate.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
run-tests.sh runs all suites: JS (standard + full), Python, OCaml,
Playwright (isomorphic + demos). deploy.sh calls it as gate.
Register log-info and log-warn as PRIMITIVES so runtime-eval'd SX code
(init-client.sx.txt) can use them.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>