Fix island reactivity: trampoline callLambda result in dom-on handlers

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>
This commit is contained in:
2026-03-24 19:26:43 +00:00
parent 32df71abd4
commit 5b2ef0a2af
2 changed files with 35 additions and 11 deletions

View File

@@ -14,7 +14,7 @@
// =========================================================================
var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } });
var SX_VERSION = "2026-03-24T18:28:36Z";
var SX_VERSION = "2026-03-24T19:19:01Z";
function isNil(x) { return x === NIL || x === null || x === undefined; }
function isSxTruthy(x) { return x !== false && !isNil(x); }
@@ -3159,6 +3159,12 @@ PRIMITIVES["SVG_NS"] = SVG_NS;
var MATH_NS = "http://www.w3.org/1998/Math/MathML";
PRIMITIVES["MATH_NS"] = MATH_NS;
// dom-on
var domOn = function(el, name, handler) { return domListen(el, name, (isSxTruthy(isLambda(handler)) ? (isSxTruthy((0 == len(lambdaParams(handler)))) ? function() { trampoline(callLambda(handler, []));
return runPostRenderHooks(); } : function(e) { trampoline(callLambda(handler, [e]));
return runPostRenderHooks(); }) : handler)); };
PRIMITIVES["dom-on"] = domOn;
// render-to-dom
var renderToDom = function(expr, env, ns) { setRenderActiveB(true);
return (function() { var _m = typeOf(expr); if (_m == "nil") return createFragment(); if (_m == "boolean") return createFragment(); if (_m == "raw-html") return domParseHtml(rawHtmlContent(expr)); if (_m == "string") return createTextNode(expr); if (_m == "number") return createTextNode((String(expr))); if (_m == "symbol") return renderToDom(trampoline(evalExpr(expr, env)), env, ns); if (_m == "keyword") return createTextNode(keywordName(expr)); if (_m == "dom-node") return expr; if (_m == "spread") return (sxEmit("element-attrs", spreadAttrs(expr)), expr); if (_m == "dict") return createFragment(); if (_m == "list") return (isSxTruthy(isEmpty(expr)) ? createFragment() : renderDomList(expr, env, ns)); return (isSxTruthy(isSignal(expr)) ? (isSxTruthy(sxContext("sx-island-scope", NIL)) ? reactiveText(expr) : createTextNode((String(deref(expr))))) : createTextNode((String(expr)))); })(); };
@@ -3200,7 +3206,7 @@ PRIMITIVES["render-dom-list"] = renderDomList;
var attrExpr = nth(args, (get(state, "i") + 1));
(isSxTruthy(startsWith(attrName, "on-")) ? (function() {
var attrVal = trampoline(evalExpr(attrExpr, env));
return (isSxTruthy(isCallable(attrVal)) ? domListen(el, slice(attrName, 3), attrVal) : NIL);
return (isSxTruthy(isCallable(attrVal)) ? domOn(el, slice(attrName, 3), attrVal) : NIL);
})() : (isSxTruthy((attrName == "bind")) ? (function() {
var attrVal = trampoline(evalExpr(attrExpr, env));
return (isSxTruthy(isSignal(attrVal)) ? bindInput(el, attrVal) : NIL);
@@ -3707,7 +3713,7 @@ PRIMITIVES["reactive-list"] = reactiveList;
var v = (String(deref(sig)));
return (isSxTruthy((domGetProp(el, "value") != v)) ? domSetProp(el, "value", v) : NIL);
})()); });
return domListen(el, (isSxTruthy(isCheckbox) ? "change" : "input"), function(e) { return (isSxTruthy(isCheckbox) ? reset_b(sig, domGetProp(el, "checked")) : reset_b(sig, domGetProp(el, "value"))); });
return domOn(el, (isSxTruthy(isCheckbox) ? "change" : "input"), function(e) { return (isSxTruthy(isCheckbox) ? reset_b(sig, domGetProp(el, "checked")) : reset_b(sig, domGetProp(el, "value"))); });
})(); };
PRIMITIVES["bind-input"] = bindInput;
@@ -4763,7 +4769,7 @@ PRIMITIVES["bind-sse-swap"] = bindSseSwap;
var eventName = slice(name, 6);
return (isSxTruthy(!isSxTruthy(isProcessed(el, (String("on:") + String(eventName))))) ? (markProcessed(el, (String("on:") + String(eventName))), (function() {
var exprs = sxParse(body);
return domListen(el, eventName, function(e) { return (function() {
return domOn(el, eventName, function(e) { return (function() {
var handlerEnv = envExtend({});
envBind(handlerEnv, "event", e);
envBind(handlerEnv, "this", el);
@@ -4820,7 +4826,7 @@ PRIMITIVES["process-one"] = processOne;
var els = domQueryAll(sxOr(root, domBody()), "[data-sx-emit]");
return forEach(function(el) { return (isSxTruthy(!isSxTruthy(isProcessed(el, "emit"))) ? (markProcessed(el, "emit"), (function() {
var eventName = domGetAttr(el, "data-sx-emit");
return (isSxTruthy(eventName) ? domListen(el, "click", function(e) { return (function() {
return (isSxTruthy(eventName) ? domOn(el, "click", function(e) { return (function() {
var detailJson = domGetAttr(el, "data-sx-emit-detail");
var detail = (isSxTruthy(detailJson) ? jsonParse(detailJson) : {});
return domDispatch(el, eventName, detail);
@@ -5967,12 +5973,12 @@ PRIMITIVES["clear-stores"] = clearStores;
PRIMITIVES["emit-event"] = emitEvent;
// on-event
var onEvent = function(el, eventName, handler) { return domListen(el, eventName, handler); };
var onEvent = function(el, eventName, handler) { return domOn(el, eventName, handler); };
PRIMITIVES["on-event"] = onEvent;
// bridge-event
var bridgeEvent = function(el, eventName, targetSignal, transformFn) { return effect(function() { return (function() {
var remove = domListen(el, eventName, function(e) { return (function() {
var remove = domOn(el, eventName, function(e) { return (function() {
var detail = eventDetail(e);
var newVal = (isSxTruthy(transformFn) ? cekCall(transformFn, [detail]) : detail);
return reset_b(targetSignal, newVal);
@@ -6321,8 +6327,8 @@ PRIMITIVES["resource"] = resource;
// If lambda takes 0 params, call without event arg (convenience for on-click handlers)
var wrapped = isLambda(handler)
? (lambdaParams(handler).length === 0
? function(e) { try { cekCall(handler, NIL); } catch(err) { console.error("[sx-ref] domListen handler error:", name, err); } finally { runPostRenderHooks(); } }
: function(e) { try { cekCall(handler, [e]); } catch(err) { console.error("[sx-ref] domListen handler error:", name, err); } finally { runPostRenderHooks(); } })
? function(e) { try { cekCall(handler, NIL); } catch(err) { console.error("[sx-ref] domListen handler error:", name, err); } }
: function(e) { try { cekCall(handler, [e]); } catch(err) { console.error("[sx-ref] domListen handler error:", name, err); } })
: handler;
if (name === "click") logInfo("domListen: click on <" + (el.tagName||"?").toLowerCase() + "> text=" + (el.textContent||"").substring(0,20) + " isLambda=" + isLambda(handler));
var passiveEvents = { touchstart: 1, touchmove: 1, wheel: 1, scroll: 1 };

View File

@@ -14,6 +14,24 @@
(define MATH_NS "http://www.w3.org/1998/Math/MathML")
;; --------------------------------------------------------------------------
;; dom-on — dom-listen with post-render hooks
;;
;; Wraps dom-listen so that run-post-render-hooks fires after every SX
;; event handler invocation. This is the SX-level hook integration;
;; the native dom-listen primitive is a clean addEventListener wrapper.
;; --------------------------------------------------------------------------
(define dom-on :effects [io]
(fn (el name handler)
(dom-listen el name
(if (lambda? handler)
(if (= 0 (len (lambda-params handler)))
(fn () (trampoline (call-lambda handler (list))) (run-post-render-hooks))
(fn (e) (trampoline (call-lambda handler (list e))) (run-post-render-hooks)))
handler))))
;; --------------------------------------------------------------------------
;; render-to-dom — main entry point
;; --------------------------------------------------------------------------
@@ -199,7 +217,7 @@
(starts-with? attr-name "on-")
(let ((attr-val (trampoline (eval-expr attr-expr env))))
(when (callable? attr-val)
(dom-listen el (slice attr-name 3) attr-val)))
(dom-on el (slice attr-name 3) attr-val)))
;; Two-way input binding: :bind signal
(= attr-name "bind")
(let ((attr-val (trampoline (eval-expr attr-expr env))))
@@ -1100,7 +1118,7 @@
(when (!= (dom-get-prop el "value") v)
(dom-set-prop el "value" v))))))
;; Element → signal (event listener)
(dom-listen el (if is-checkbox "change" "input")
(dom-on el (if is-checkbox "change" "input")
(fn (e)
(if is-checkbox
(reset! sig (dom-get-prop el "checked"))