Fix lambda multi-body, reactive island demos, and add React is Hypermedia essay
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled

Lambda multi-body fix: sf-lambda used (nth args 1), dropping all but the first
body expression. Fixed to collect all body expressions and wrap in (begin ...).
This was foundational — every multi-expression lambda in every island silently
dropped expressions after the first.

Reactive islands: fix dom-parent marker timing (first effect run before marker
is in DOM), fix :key eager evaluation, fix error boundary scope isolation,
fix resource/suspense reactive cond tracking, fix inc not available as JS var.

New essay: "React is Hypermedia" — argues that reactive islands are hypermedia
controls whose behavior is specified in SX, not a departure from hypermedia.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 20:00:44 +00:00
parent 06adbdcd59
commit 56589a81b2
12 changed files with 1047 additions and 4010 deletions

View File

@@ -169,34 +169,42 @@
(< (inc (get state "i")) (len args)))
;; Keyword arg → attribute
(let ((attr-name (keyword-name arg))
(attr-val (trampoline
(eval-expr
(nth args (inc (get state "i")))
env))))
(attr-expr (nth args (inc (get state "i")))))
(cond
;; nil or false → skip
(or (nil? attr-val) (= attr-val false))
nil
;; Event handler: on-click, on-submit, on-input, etc.
;; Value must be callable (lambda/function)
(and (starts-with? attr-name "on-")
(callable? attr-val))
(dom-listen el (slice attr-name 3) attr-val)
;; Event handler: evaluate eagerly, bind listener
(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)))
;; Two-way input binding: :bind signal
(and (= attr-name "bind") (signal? attr-val))
(bind-input el attr-val)
(= attr-name "bind")
(let ((attr-val (trampoline (eval-expr attr-expr env))))
(when (signal? attr-val) (bind-input el attr-val)))
;; ref: set ref.current to this element
(= attr-name "ref")
(dict-set! attr-val "current" el)
;; Boolean attr
(contains? BOOLEAN_ATTRS attr-name)
(when attr-val (dom-set-attr el attr-name ""))
;; true → empty attr
(= attr-val true)
(dom-set-attr el attr-name "")
;; Normal attr
(let ((attr-val (trampoline (eval-expr attr-expr env))))
(dict-set! attr-val "current" el))
;; key: reconciliation hint, evaluate eagerly (not reactive)
(= attr-name "key")
(let ((attr-val (trampoline (eval-expr attr-expr env))))
(dom-set-attr el "key" (str attr-val)))
;; Inside island scope: reactive attribute binding.
;; The effect tracks signal deps automatically — if none
;; are deref'd, it fires once and never again (safe).
*island-scope*
(reactive-attr el attr-name
(fn () (trampoline (eval-expr attr-expr env))))
;; Static attribute (outside islands)
:else
(dom-set-attr el attr-name (str attr-val)))
(let ((attr-val (trampoline (eval-expr attr-expr env))))
(cond
(or (nil? attr-val) (= attr-val false)) nil
(contains? BOOLEAN_ATTRS attr-name)
(when attr-val (dom-set-attr el attr-name ""))
(= attr-val true)
(dom-set-attr el attr-name "")
:else
(dom-set-attr el attr-name (str attr-val)))))
(assoc state "skip" true "i" (inc (get state "i"))))
;; Positional arg → child
@@ -319,32 +327,131 @@
(define dispatch-render-form
(fn (name expr env ns)
(cond
;; if
;; if — reactive inside islands (re-renders when signal deps change)
(= name "if")
(let ((cond-val (trampoline (eval-expr (nth expr 1) env))))
(if cond-val
(render-to-dom (nth expr 2) env ns)
(if (> (len expr) 3)
(render-to-dom (nth expr 3) env ns)
(create-fragment))))
(if *island-scope*
(let ((marker (create-comment "r-if"))
(current-nodes (list))
(initial-result nil))
;; Effect runs synchronously on first call, tracking signal deps.
;; On first run, store result in initial-result (marker has no parent yet).
;; On subsequent runs, swap DOM nodes after marker.
(effect (fn ()
(let ((result (let ((cond-val (trampoline (eval-expr (nth expr 1) env))))
(if cond-val
(render-to-dom (nth expr 2) env ns)
(if (> (len expr) 3)
(render-to-dom (nth expr 3) env ns)
(create-fragment))))))
(if (dom-parent marker)
;; Marker is in DOM — swap nodes
(do
(for-each (fn (n) (dom-remove n)) current-nodes)
(set! current-nodes
(if (dom-is-fragment? result)
(dom-child-nodes result)
(list result)))
(dom-insert-after marker result))
;; Marker not yet in DOM (first run) — just save result
(set! initial-result result)))))
;; Return fragment: marker + initial render result
(let ((frag (create-fragment)))
(dom-append frag marker)
(when initial-result
(set! current-nodes
(if (dom-is-fragment? initial-result)
(dom-child-nodes initial-result)
(list initial-result)))
(dom-append frag initial-result))
frag))
;; Static if
(let ((cond-val (trampoline (eval-expr (nth expr 1) env))))
(if cond-val
(render-to-dom (nth expr 2) env ns)
(if (> (len expr) 3)
(render-to-dom (nth expr 3) env ns)
(create-fragment)))))
;; when
;; when — reactive inside islands
(= name "when")
(if (not (trampoline (eval-expr (nth expr 1) env)))
(create-fragment)
(let ((frag (create-fragment)))
(for-each
(fn (i)
(dom-append frag (render-to-dom (nth expr i) env ns)))
(range 2 (len expr)))
frag))
(if *island-scope*
(let ((marker (create-comment "r-when"))
(current-nodes (list))
(initial-result nil))
(effect (fn ()
(if (dom-parent marker)
;; In DOM — swap nodes
(do
(for-each (fn (n) (dom-remove n)) current-nodes)
(set! current-nodes (list))
(when (trampoline (eval-expr (nth expr 1) env))
(let ((frag (create-fragment)))
(for-each
(fn (i)
(dom-append frag (render-to-dom (nth expr i) env ns)))
(range 2 (len expr)))
(set! current-nodes (dom-child-nodes frag))
(dom-insert-after marker frag))))
;; First run — save result for fragment
(when (trampoline (eval-expr (nth expr 1) env))
(let ((frag (create-fragment)))
(for-each
(fn (i)
(dom-append frag (render-to-dom (nth expr i) env ns)))
(range 2 (len expr)))
(set! current-nodes (dom-child-nodes frag))
(set! initial-result frag))))))
(let ((frag (create-fragment)))
(dom-append frag marker)
(when initial-result (dom-append frag initial-result))
frag))
;; Static when
(if (not (trampoline (eval-expr (nth expr 1) env)))
(create-fragment)
(let ((frag (create-fragment)))
(for-each
(fn (i)
(dom-append frag (render-to-dom (nth expr i) env ns)))
(range 2 (len expr)))
frag)))
;; cond
;; cond — reactive inside islands
(= name "cond")
(let ((branch (eval-cond (rest expr) env)))
(if branch
(render-to-dom branch env ns)
(create-fragment)))
(if *island-scope*
(let ((marker (create-comment "r-cond"))
(current-nodes (list))
(initial-result nil))
(effect (fn ()
(let ((branch (eval-cond (rest expr) env)))
(if (dom-parent marker)
;; In DOM — swap nodes
(do
(for-each (fn (n) (dom-remove n)) current-nodes)
(set! current-nodes (list))
(when branch
(let ((result (render-to-dom branch env ns)))
(set! current-nodes
(if (dom-is-fragment? result)
(dom-child-nodes result)
(list result)))
(dom-insert-after marker result))))
;; First run — save result for fragment
(when branch
(let ((result (render-to-dom branch env ns)))
(set! current-nodes
(if (dom-is-fragment? result)
(dom-child-nodes result)
(list result)))
(set! initial-result result)))))))
(let ((frag (create-fragment)))
(dom-append frag marker)
(when initial-result (dom-append frag initial-result))
frag))
;; Static cond
(let ((branch (eval-cond (rest expr) env)))
(if branch
(render-to-dom branch env ns)
(create-fragment))))
;; case
(= name "case")
@@ -429,11 +536,11 @@
;; portal — render children into a remote target element
(= name "portal")
(render-dom-portal args env ns)
(render-dom-portal (rest expr) env ns)
;; error-boundary — catch errors, render fallback
(= name "error-boundary")
(render-dom-error-boundary args env ns)
(render-dom-error-boundary (rest expr) env ns)
;; for-each (render variant)
(= name "for-each")
@@ -620,9 +727,9 @@
(key-order (list)))
(dom-append container marker)
(effect (fn ()
(let ((parent (dom-parent marker))
(items (deref items-sig)))
(when parent
(let ((items (deref items-sig)))
(if (dom-parent marker)
;; Marker in DOM: reconcile
(let ((new-map (dict))
(new-keys (list))
(has-keys false))
@@ -674,7 +781,17 @@
;; Update state for next render
(set! key-map new-map)
(set! key-order new-keys))))))
(set! key-order new-keys))
;; First run (marker not in DOM yet): render initial items into container
(for-each-indexed
(fn (idx item)
(let ((rendered (render-list-item map-fn item env ns))
(key (extract-key rendered idx)))
(dict-set! key-map key rendered)
(append! key-order key)
(dom-append container rendered)))
items)))))
container)))
@@ -726,12 +843,10 @@
(define render-dom-portal
(fn (args env ns)
(let ((selector (trampoline (eval-expr (first args) env)))
(target (dom-query selector)))
(target (or (dom-query selector)
(dom-ensure-element selector))))
(if (not target)
;; Target not found — render nothing, log warning
(do
(log-warn (str "Portal target not found: " selector))
(create-comment (str "portal: " selector " (not found)")))
(create-comment (str "portal: " selector " (not found)"))
(let ((marker (create-comment (str "portal: " selector)))
(frag (create-fragment)))
;; Render children into the fragment
@@ -770,58 +885,49 @@
(let ((fallback-expr (first args))
(body-exprs (rest args))
(container (dom-create-element "div" nil))
(boundary-disposers (list)))
;; retry-version: bump this signal to force re-render after fallback
(retry-version (signal 0)))
(dom-set-attr container "data-sx-boundary" "true")
;; Render body with its own island scope for disposal
(let ((render-body
(fn ()
;; Dispose old boundary content
(for-each (fn (d) (d)) boundary-disposers)
(set! boundary-disposers (list))
;; The entire body is rendered inside ONE effect + try-catch.
;; Body renders WITHOUT *island-scope* so that if/when/cond use static
;; paths — their signal reads become direct deref calls tracked by THIS
;; effect. Errors from signal changes throw synchronously within try-catch.
;; The error boundary's own effect handles all reactivity for its subtree.
(effect (fn ()
;; Touch retry-version so the effect re-runs when retry is called
(deref retry-version)
;; Clear container
(dom-set-prop container "innerHTML" "")
;; Clear container
(dom-set-prop container "innerHTML" "")
;; Try to render body
(try-catch
(fn ()
;; Render body children, tracking disposers
(with-island-scope
(fn (disposable)
(append! boundary-disposers disposable)
(register-in-scope disposable))
(fn ()
(let ((frag (create-fragment)))
(for-each
(fn (child)
(dom-append frag (render-to-dom child env ns)))
body-exprs)
(dom-append container frag)))))
(fn (err)
;; Dispose any partially-created effects
(for-each (fn (d) (d)) boundary-disposers)
(set! boundary-disposers (list))
;; Save and clear island scope BEFORE try-catch so it can be
;; restored in both success and error paths.
(let ((saved-scope *island-scope*))
(set! *island-scope* nil)
(try-catch
(fn ()
;; Body renders statically — signal reads tracked by THIS effect,
;; throws propagate to our try-catch.
(let ((frag (create-fragment)))
(for-each
(fn (child)
(dom-append frag (render-to-dom child env ns)))
body-exprs)
(dom-append container frag))
(set! *island-scope* saved-scope))
(fn (err)
;; Restore scope first, then render fallback
(set! *island-scope* saved-scope)
(let ((fallback-fn (trampoline (eval-expr fallback-expr env)))
(retry-fn (fn () (swap! retry-version (fn (n) (+ n 1))))))
(let ((fallback-dom
(if (lambda? fallback-fn)
(render-lambda-dom fallback-fn (list err retry-fn) env ns)
(render-to-dom (apply fallback-fn (list err retry-fn)) env ns))))
(dom-append container fallback-dom))))))))
;; Render fallback with error + retry
(let ((fallback-fn (trampoline (eval-expr fallback-expr env)))
(retry-fn (fn () (render-body))))
(let ((fallback-dom
(if (lambda? fallback-fn)
(render-lambda-dom fallback-fn (list err retry-fn) env ns)
(render-to-dom (apply fallback-fn (list err retry-fn)) env ns))))
(dom-append container fallback-dom))))))))
;; Initial render
(render-body)
;; Register boundary disposers with parent island scope
(register-in-scope
(fn ()
(for-each (fn (d) (d)) boundary-disposers)
(set! boundary-disposers (list))))
container))))
container)))
;; --------------------------------------------------------------------------

View File

@@ -102,9 +102,11 @@ class JSEmitter:
# Map SX names to JS names
return self._mangle(name)
def _mangle(self, name: str) -> str:
"""Convert SX identifier to valid JS identifier."""
RENAMES = {
# Explicit SX→JS name mappings. Auto-mangle (kebab→camelCase, ?→_p, !→_b)
# is only a fallback. Every platform symbol used in spec .sx files MUST have
# an entry here — relying on auto-mangle is fragile and has caused runtime
# errors (e.g. has-key? → hasKey_p instead of dictHas).
RENAMES = {
"nil": "NIL",
"true": "true",
"false": "false",
@@ -328,6 +330,7 @@ class JSEmitter:
"dom-listen": "domListen",
"event-detail": "eventDetail",
"dom-query": "domQuery",
"dom-ensure-element": "domEnsureElement",
"dom-query-all": "domQueryAll",
"dom-tag-name": "domTagName",
"create-comment": "createComment",
@@ -338,6 +341,7 @@ class JSEmitter:
"dom-get-data": "domGetData",
"json-parse": "jsonParse",
"dict-has?": "dictHas",
"has-key?": "dictHas",
"dict-delete!": "dictDelete",
"process-bindings": "processBindings",
"eval-cond": "evalCond",
@@ -413,6 +417,7 @@ class JSEmitter:
"promise-resolve": "promiseResolve",
"promise-then": "promiseThen",
"promise-catch": "promiseCatch",
"promise-delayed": "promiseDelayed",
"abort-previous": "abortPrevious",
"track-controller": "trackController",
"new-abort-controller": "newAbortController",
@@ -590,9 +595,12 @@ class JSEmitter:
"match-route": "matchRoute",
"find-matching-route": "findMatchingRoute",
"for-each-indexed": "forEachIndexed",
}
if name in RENAMES:
return RENAMES[name]
}
def _mangle(self, name: str) -> str:
"""Convert SX identifier to valid JS identifier."""
if name in self.RENAMES:
return self.RENAMES[name]
# General mangling: replace - with camelCase, ? with _p, ! with _b
result = name
if result.endswith("?"):
@@ -2196,6 +2204,7 @@ PRIMITIVES_JS_MODULES: dict[str, str] = {
PRIMITIVES["odd?"] = function(n) { return n % 2 !== 0; };
PRIMITIVES["even?"] = function(n) { return n % 2 === 0; };
PRIMITIVES["zero?"] = function(n) { return n === 0; };
PRIMITIVES["boolean?"] = function(x) { return x === true || x === false; };
''',
"core.strings": '''
@@ -2219,6 +2228,7 @@ PRIMITIVES_JS_MODULES: dict[str, str] = {
PRIMITIVES["slice"] = function(c, a, b) { return b !== undefined ? c.slice(a, b) : c.slice(a); };
PRIMITIVES["substring"] = function(s, a, b) { return String(s).substring(a, b); };
PRIMITIVES["string-length"] = function(s) { return String(s).length; };
PRIMITIVES["string-contains?"] = function(s, sub) { return String(s).indexOf(String(sub)) !== -1; };
PRIMITIVES["concat"] = function() {
var out = [];
for (var i = 0; i < arguments.length; i++) if (!isNil(arguments[i])) out = out.concat(arguments[i]);
@@ -2254,6 +2264,12 @@ PRIMITIVES_JS_MODULES: dict[str, str] = {
PRIMITIVES["zip-pairs"] = function(c) {
var r = []; for (var i = 0; i < c.length - 1; i++) r.push([c[i], c[i + 1]]); return r;
};
PRIMITIVES["reverse"] = function(c) { return Array.isArray(c) ? c.slice().reverse() : String(c).split("").reverse().join(""); };
PRIMITIVES["flatten"] = function(c) {
var out = [];
function walk(a) { for (var i = 0; i < a.length; i++) Array.isArray(a[i]) ? walk(a[i]) : out.push(a[i]); }
walk(c || []); return out;
};
''',
"core.dict": '''
@@ -2276,6 +2292,7 @@ PRIMITIVES_JS_MODULES: dict[str, str] = {
return out;
};
PRIMITIVES["dict-set!"] = function(d, k, v) { d[k] = v; return v; };
PRIMITIVES["has-key?"] = function(d, k) { return d !== null && d !== undefined && k in d; };
PRIMITIVES["into"] = function(target, coll) {
if (Array.isArray(target)) return Array.isArray(coll) ? coll.slice() : Object.entries(coll);
var r = {}; for (var i = 0; i < coll.length; i++) { var p = coll[i]; if (Array.isArray(p) && p.length >= 2) r[p[0]] = p[1]; }
@@ -2906,6 +2923,20 @@ PLATFORM_DOM_JS = """
return _hasDom ? document.querySelector(sel) : null;
}
function domEnsureElement(sel) {
if (!_hasDom) return null;
var el = document.querySelector(sel);
if (el) return el;
// Parse #id selector → create div with that id, append to body
if (sel.charAt(0) === '#') {
el = document.createElement('div');
el.id = sel.slice(1);
document.body.appendChild(el);
return el;
}
return null;
}
function domQueryAll(root, sel) {
if (!root || !root.querySelectorAll) return [];
return Array.prototype.slice.call(root.querySelectorAll(sel));
@@ -3039,6 +3070,12 @@ PLATFORM_ORCHESTRATION_JS = """
function promiseCatch(p, fn) { return p && p.catch ? p.catch(fn) : p; }
function promiseDelayed(ms, value) {
return new Promise(function(resolve) {
setTimeout(function() { resolve(value); }, ms);
});
}
// --- Abort controllers ---
var _controllers = typeof WeakMap !== "undefined" ? new WeakMap() : null;
@@ -3075,8 +3112,9 @@ PLATFORM_ORCHESTRATION_JS = """
function clearTimeout_(id) { clearTimeout(id); }
function clearInterval_(id) { clearInterval(id); }
function requestAnimationFrame_(fn) {
if (typeof requestAnimationFrame !== "undefined") requestAnimationFrame(fn);
else setTimeout(fn, 16);
var cb = _wrapSxFn(fn);
if (typeof requestAnimationFrame !== "undefined") requestAnimationFrame(cb);
else setTimeout(cb, 16);
}
// --- Fetch ---
@@ -3475,14 +3513,19 @@ PLATFORM_ORCHESTRATION_JS = """
function stopPropagation_(e) { if (e && e.stopPropagation) e.stopPropagation(); }
function domFocus(el) { if (el && el.focus) el.focus(); }
function tryCatch(tryFn, catchFn) {
try { return tryFn(); } catch (e) { return catchFn(e); }
var t = _wrapSxFn(tryFn);
var c = catchFn && catchFn._lambda
? function(e) { return trampoline(callLambda(catchFn, [e], lambdaClosure(catchFn))); }
: catchFn;
try { return t(); } catch (e) { return c(e); }
}
function errorMessage(e) {
return e && e.message ? e.message : String(e);
}
function scheduleIdle(fn) {
if (typeof requestIdleCallback !== "undefined") requestIdleCallback(fn);
else setTimeout(fn, 0);
var cb = _wrapSxFn(fn);
if (typeof requestIdleCallback !== "undefined") requestIdleCallback(cb);
else setTimeout(cb, 0);
}
function elementValue(el) { return el && el.value !== undefined ? el.value : NIL; }
@@ -4028,11 +4071,24 @@ def fixups_js(has_html, has_sx, has_dom, has_signals=False):
PRIMITIVES["dom-listen"] = domListen;
PRIMITIVES["dom-dispatch"] = domDispatch;
PRIMITIVES["event-detail"] = eventDetail;
PRIMITIVES["resource"] = resource;
PRIMITIVES["promise-delayed"] = promiseDelayed;
PRIMITIVES["promise-then"] = promiseThen;
PRIMITIVES["def-store"] = defStore;
PRIMITIVES["use-store"] = useStore;
PRIMITIVES["emit-event"] = emitEvent;
PRIMITIVES["on-event"] = onEvent;
PRIMITIVES["bridge-event"] = bridgeEvent;''')
PRIMITIVES["bridge-event"] = bridgeEvent;
// DOM primitives for island code
PRIMITIVES["dom-focus"] = domFocus;
PRIMITIVES["dom-tag-name"] = domTagName;
PRIMITIVES["dom-get-prop"] = domGetProp;
PRIMITIVES["stop-propagation"] = stopPropagation_;
PRIMITIVES["error-message"] = errorMessage;
PRIMITIVES["schedule-idle"] = scheduleIdle;
PRIMITIVES["invoke"] = invoke;
PRIMITIVES["error"] = function(msg) { throw new Error(msg); };
PRIMITIVES["filter"] = filter;''')
return "\n".join(lines)

View File

@@ -476,7 +476,10 @@
(define sf-lambda
(fn (args env)
(let ((params-expr (first args))
(body (nth args 1))
(body-exprs (rest args))
(body (if (= (len body-exprs) 1)
(first body-exprs)
(cons (make-symbol "begin") body-exprs)))
(param-names (map (fn (p)
(if (= (type-of p) "symbol")
(symbol-name p)

View File

@@ -337,7 +337,32 @@
(deftest "higher-order returns lambda"
(let ((make-adder (fn (n) (fn (x) (+ n x)))))
(let ((add5 (make-adder 5)))
(assert-equal 8 (add5 3))))))
(assert-equal 8 (add5 3)))))
(deftest "multi-body lambda returns last value"
;; All body expressions must execute. Return value is the last.
;; Catches: sf-lambda using nth(args,1) instead of rest(args).
(let ((f (fn (x) (+ x 1) (+ x 2) (+ x 3))))
(assert-equal 13 (f 10))))
(deftest "multi-body lambda side effects via dict mutation"
;; Verify all body expressions run by mutating a shared dict.
(let ((state (dict "a" 0 "b" 0)))
(let ((f (fn ()
(dict-set! state "a" 1)
(dict-set! state "b" 2)
"done")))
(assert-equal "done" (f))
(assert-equal 1 (get state "a"))
(assert-equal 2 (get state "b")))))
(deftest "multi-body lambda two expressions"
;; Simplest case: two body expressions, return value is second.
(assert-equal 20
((fn (x) (+ x 1) (* x 2)) 10))
;; And with zero-arg lambda
(assert-equal 42
((fn () (+ 1 2) 42)))))
;; --------------------------------------------------------------------------