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:
@@ -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 };
|
||||
|
||||
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user