Remove reactive class/style (CSSX covers it), add error boundaries + resource

Reactive class/style (:class-map, :style-map) removed — CSSX components
already handle dynamic class/style via defcomp with full SX logic.

Added:
- error-boundary render-dom form: try/catch around children, renders
  fallback fn with (err retry) on failure, disposes partial effects
- resource async signal: wraps promise into signal with loading/data/error
  states, transitions automatically on resolve/reject
- try-catch, error-message, promise-then platform functions
- Updated Phase 2 status tables and demo page numbering

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 16:35:19 +00:00
parent 6bda2bafa2
commit a496ee6ae6
6 changed files with 228 additions and 149 deletions

View File

@@ -14,7 +14,7 @@
// =========================================================================
var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } });
var SX_VERSION = "2026-03-08T16:24:34Z";
var SX_VERSION = "2026-03-08T16:35:00Z";
function isNil(x) { return x === NIL || x === null || x === undefined; }
function isSxTruthy(x) { return x !== false && !isNil(x); }
@@ -1492,7 +1492,7 @@ return result; }, args);
return (isSxTruthy(skip) ? assoc(state, "skip", false, "i", (get(state, "i") + 1)) : (isSxTruthy((isSxTruthy((typeOf(arg) == "keyword")) && ((get(state, "i") + 1) < len(args)))) ? (function() {
var attrName = keywordName(arg);
var attrVal = trampoline(evalExpr(nth(args, (get(state, "i") + 1)), env));
(isSxTruthy(sxOr(isNil(attrVal), (attrVal == false))) ? NIL : (isSxTruthy((isSxTruthy(startsWith(attrName, "on-")) && isCallable(attrVal))) ? domListen(el, slice(attrName, 3), attrVal) : (isSxTruthy((isSxTruthy((attrName == "bind")) && isSignal(attrVal))) ? bindInput(el, attrVal) : (isSxTruthy((attrName == "class-map")) ? reactiveClassMap(el, attrVal) : (isSxTruthy((attrName == "style-map")) ? reactiveStyleMap(el, attrVal) : (isSxTruthy((attrName == "ref")) ? refSet_b(attrVal, el) : (isSxTruthy(contains(BOOLEAN_ATTRS, attrName)) ? (isSxTruthy(attrVal) ? domSetAttr(el, attrName, "") : NIL) : (isSxTruthy((attrVal == true)) ? domSetAttr(el, attrName, "") : domSetAttr(el, attrName, (String(attrVal)))))))))));
(isSxTruthy(sxOr(isNil(attrVal), (attrVal == false))) ? NIL : (isSxTruthy((isSxTruthy(startsWith(attrName, "on-")) && isCallable(attrVal))) ? domListen(el, slice(attrName, 3), attrVal) : (isSxTruthy((isSxTruthy((attrName == "bind")) && isSignal(attrVal))) ? bindInput(el, attrVal) : (isSxTruthy((attrName == "ref")) ? refSet_b(attrVal, el) : (isSxTruthy(contains(BOOLEAN_ATTRS, attrName)) ? (isSxTruthy(attrVal) ? domSetAttr(el, attrName, "") : NIL) : (isSxTruthy((attrVal == true)) ? domSetAttr(el, attrName, "") : domSetAttr(el, attrName, (String(attrVal)))))))));
return assoc(state, "skip", true, "i", (get(state, "i") + 1));
})() : ((isSxTruthy(!isSxTruthy(contains(VOID_ELEMENTS, tag))) ? domAppend(el, renderToDom(arg, env, newNs)) : NIL), assoc(state, "i", (get(state, "i") + 1)))));
})(); }, {["i"]: 0, ["skip"]: false}, args);
@@ -1546,7 +1546,7 @@ return result; }, args);
var renderDomUnknownComponent = function(name) { return error((String("Unknown component: ") + String(name))); };
// RENDER_DOM_FORMS
var RENDER_DOM_FORMS = ["if", "when", "cond", "case", "let", "let*", "begin", "do", "define", "defcomp", "defisland", "defmacro", "defstyle", "defhandler", "map", "map-indexed", "filter", "for-each", "portal"];
var RENDER_DOM_FORMS = ["if", "when", "cond", "case", "let", "let*", "begin", "do", "define", "defcomp", "defisland", "defmacro", "defstyle", "defhandler", "map", "map-indexed", "filter", "for-each", "portal", "error-boundary"];
// render-dom-form?
var isRenderDomForm = function(name) { return contains(RENDER_DOM_FORMS, name); };
@@ -1604,7 +1604,7 @@ return result; }, args);
return domAppend(frag, val);
})(); }, coll);
return frag;
})() : (isSxTruthy((name == "filter")) ? renderToDom(trampoline(evalExpr(expr, env)), env, ns) : (isSxTruthy((name == "portal")) ? renderDomPortal(args, env, ns) : (isSxTruthy((name == "for-each")) ? (function() {
})() : (isSxTruthy((name == "filter")) ? renderToDom(trampoline(evalExpr(expr, env)), env, ns) : (isSxTruthy((name == "portal")) ? renderDomPortal(args, env, ns) : (isSxTruthy((name == "error-boundary")) ? renderDomErrorBoundary(args, env, ns) : (isSxTruthy((name == "for-each")) ? (function() {
var f = trampoline(evalExpr(nth(expr, 1), env));
var coll = trampoline(evalExpr(nth(expr, 2), env));
var frag = createFragment();
@@ -1613,7 +1613,7 @@ return result; }, args);
return domAppend(frag, val);
})(); } }
return frag;
})() : renderToDom(trampoline(evalExpr(expr, env)), env, ns))))))))))))); };
})() : renderToDom(trampoline(evalExpr(expr, env)), env, ns)))))))))))))); };
// render-lambda-dom
var renderLambdaDom = function(f, args, env, ns) { return (function() {
@@ -1755,15 +1755,6 @@ return (isSxTruthy(testFn()) ? (function() {
return domListen(el, (isSxTruthy(isCheckbox) ? "change" : "input"), function(e) { return (isSxTruthy(isCheckbox) ? reset_b(sig, domGetProp(el, "checked")) : reset_b(sig, domGetProp(el, "value"))); });
})(); };
// reactive-class-map
var reactiveClassMap = function(el, classDict) { return effect(function() { return forEach(function(cls) { return (function() {
var val = deref(get(classDict, cls));
return (isSxTruthy(val) ? domAddClass(el, cls) : domRemoveClass(el, cls));
})(); }, keys(classDict)); }); };
// reactive-style-map
var reactiveStyleMap = function(el, styleDict) { return effect(function() { return forEach(function(prop) { return domSetStyle(el, prop, (String(deref(get(styleDict, prop))))); }, keys(styleDict)); }); };
// render-dom-portal
var renderDomPortal = function(args, env, ns) { return (function() {
var selector = trampoline(evalExpr(first(args), env));
@@ -1781,6 +1772,39 @@ return (isSxTruthy(testFn()) ? (function() {
})());
})(); };
// render-dom-error-boundary
var renderDomErrorBoundary = function(args, env, ns) { return (function() {
var fallbackExpr = first(args);
var bodyExprs = rest(args);
var container = domCreateElement("div", NIL);
var boundaryDisposers = [];
domSetAttr(container, "data-sx-boundary", "true");
return (function() {
var renderBody = function() { { var _c = boundaryDisposers; for (var _i = 0; _i < _c.length; _i++) { var d = _c[_i]; d(); } }
boundaryDisposers = [];
domSetProp(container, "innerHTML", "");
return tryCatch(function() { return withIslandScope(function(disposable) { boundaryDisposers.push(disposable);
return registerInScope(disposable); }, function() { return (function() {
var frag = createFragment();
{ var _c = bodyExprs; for (var _i = 0; _i < _c.length; _i++) { var child = _c[_i]; domAppend(frag, renderToDom(child, env, ns)); } }
return domAppend(container, frag);
})(); }); }, function(err) { { var _c = boundaryDisposers; for (var _i = 0; _i < _c.length; _i++) { var d = _c[_i]; d(); } }
boundaryDisposers = [];
return (function() {
var fallbackFn = trampoline(evalExpr(fallbackExpr, env));
var retryFn = function() { return renderBody(); };
return (function() {
var fallbackDom = (isSxTruthy(isLambda(fallbackFn)) ? renderLambdaDom(fallbackFn, [err, retryFn], env, ns) : renderToDom(apply(fallbackFn, [err, retryFn]), env, ns));
return domAppend(container, fallbackDom);
})();
})(); }); };
renderBody();
registerInScope(function() { { var _c = boundaryDisposers; for (var _i = 0; _i < _c.length; _i++) { var d = _c[_i]; d(); } }
return (boundaryDisposers = []); });
return container;
})();
})(); };
// === Transpiled from engine ===
@@ -3083,6 +3107,13 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() {
return remove;
})(); }); };
// resource
var resource = function(fetchFn) { return (function() {
var state = signal({["loading"]: true, ["data"]: NIL, ["error"]: NIL});
promiseThen(invoke(fetchFn), function(data) { return reset_b(state, {["loading"]: false, ["data"]: data, ["error"]: NIL}); }, function(err) { return reset_b(state, {["loading"]: false, ["data"]: NIL, ["error"]: err}); });
return state;
})(); };
// =========================================================================
// Platform interface — DOM adapter (browser-only)
@@ -3812,6 +3843,12 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() {
function preventDefault_(e) { if (e && e.preventDefault) e.preventDefault(); }
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); }
}
function errorMessage(e) {
return e && e.message ? e.message : String(e);
}
function elementValue(el) { return el && el.value !== undefined ? el.value : NIL; }
function domAddListener(el, event, fn, opts) {

View File

@@ -14,7 +14,7 @@
// =========================================================================
var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } });
var SX_VERSION = "2026-03-08T16:24:34Z";
var SX_VERSION = "2026-03-08T16:35:00Z";
function isNil(x) { return x === NIL || x === null || x === undefined; }
function isSxTruthy(x) { return x !== false && !isNil(x); }
@@ -1492,7 +1492,7 @@ return result; }, args);
return (isSxTruthy(skip) ? assoc(state, "skip", false, "i", (get(state, "i") + 1)) : (isSxTruthy((isSxTruthy((typeOf(arg) == "keyword")) && ((get(state, "i") + 1) < len(args)))) ? (function() {
var attrName = keywordName(arg);
var attrVal = trampoline(evalExpr(nth(args, (get(state, "i") + 1)), env));
(isSxTruthy(sxOr(isNil(attrVal), (attrVal == false))) ? NIL : (isSxTruthy((isSxTruthy(startsWith(attrName, "on-")) && isCallable(attrVal))) ? domListen(el, slice(attrName, 3), attrVal) : (isSxTruthy((isSxTruthy((attrName == "bind")) && isSignal(attrVal))) ? bindInput(el, attrVal) : (isSxTruthy((attrName == "class-map")) ? reactiveClassMap(el, attrVal) : (isSxTruthy((attrName == "style-map")) ? reactiveStyleMap(el, attrVal) : (isSxTruthy((attrName == "ref")) ? refSet_b(attrVal, el) : (isSxTruthy(contains(BOOLEAN_ATTRS, attrName)) ? (isSxTruthy(attrVal) ? domSetAttr(el, attrName, "") : NIL) : (isSxTruthy((attrVal == true)) ? domSetAttr(el, attrName, "") : domSetAttr(el, attrName, (String(attrVal)))))))))));
(isSxTruthy(sxOr(isNil(attrVal), (attrVal == false))) ? NIL : (isSxTruthy((isSxTruthy(startsWith(attrName, "on-")) && isCallable(attrVal))) ? domListen(el, slice(attrName, 3), attrVal) : (isSxTruthy((isSxTruthy((attrName == "bind")) && isSignal(attrVal))) ? bindInput(el, attrVal) : (isSxTruthy((attrName == "ref")) ? refSet_b(attrVal, el) : (isSxTruthy(contains(BOOLEAN_ATTRS, attrName)) ? (isSxTruthy(attrVal) ? domSetAttr(el, attrName, "") : NIL) : (isSxTruthy((attrVal == true)) ? domSetAttr(el, attrName, "") : domSetAttr(el, attrName, (String(attrVal)))))))));
return assoc(state, "skip", true, "i", (get(state, "i") + 1));
})() : ((isSxTruthy(!isSxTruthy(contains(VOID_ELEMENTS, tag))) ? domAppend(el, renderToDom(arg, env, newNs)) : NIL), assoc(state, "i", (get(state, "i") + 1)))));
})(); }, {["i"]: 0, ["skip"]: false}, args);
@@ -1546,7 +1546,7 @@ return result; }, args);
var renderDomUnknownComponent = function(name) { return error((String("Unknown component: ") + String(name))); };
// RENDER_DOM_FORMS
var RENDER_DOM_FORMS = ["if", "when", "cond", "case", "let", "let*", "begin", "do", "define", "defcomp", "defisland", "defmacro", "defstyle", "defhandler", "map", "map-indexed", "filter", "for-each", "portal"];
var RENDER_DOM_FORMS = ["if", "when", "cond", "case", "let", "let*", "begin", "do", "define", "defcomp", "defisland", "defmacro", "defstyle", "defhandler", "map", "map-indexed", "filter", "for-each", "portal", "error-boundary"];
// render-dom-form?
var isRenderDomForm = function(name) { return contains(RENDER_DOM_FORMS, name); };
@@ -1604,7 +1604,7 @@ return result; }, args);
return domAppend(frag, val);
})(); }, coll);
return frag;
})() : (isSxTruthy((name == "filter")) ? renderToDom(trampoline(evalExpr(expr, env)), env, ns) : (isSxTruthy((name == "portal")) ? renderDomPortal(args, env, ns) : (isSxTruthy((name == "for-each")) ? (function() {
})() : (isSxTruthy((name == "filter")) ? renderToDom(trampoline(evalExpr(expr, env)), env, ns) : (isSxTruthy((name == "portal")) ? renderDomPortal(args, env, ns) : (isSxTruthy((name == "error-boundary")) ? renderDomErrorBoundary(args, env, ns) : (isSxTruthy((name == "for-each")) ? (function() {
var f = trampoline(evalExpr(nth(expr, 1), env));
var coll = trampoline(evalExpr(nth(expr, 2), env));
var frag = createFragment();
@@ -1613,7 +1613,7 @@ return result; }, args);
return domAppend(frag, val);
})(); } }
return frag;
})() : renderToDom(trampoline(evalExpr(expr, env)), env, ns))))))))))))); };
})() : renderToDom(trampoline(evalExpr(expr, env)), env, ns)))))))))))))); };
// render-lambda-dom
var renderLambdaDom = function(f, args, env, ns) { return (function() {
@@ -1755,15 +1755,6 @@ return (isSxTruthy(testFn()) ? (function() {
return domListen(el, (isSxTruthy(isCheckbox) ? "change" : "input"), function(e) { return (isSxTruthy(isCheckbox) ? reset_b(sig, domGetProp(el, "checked")) : reset_b(sig, domGetProp(el, "value"))); });
})(); };
// reactive-class-map
var reactiveClassMap = function(el, classDict) { return effect(function() { return forEach(function(cls) { return (function() {
var val = deref(get(classDict, cls));
return (isSxTruthy(val) ? domAddClass(el, cls) : domRemoveClass(el, cls));
})(); }, keys(classDict)); }); };
// reactive-style-map
var reactiveStyleMap = function(el, styleDict) { return effect(function() { return forEach(function(prop) { return domSetStyle(el, prop, (String(deref(get(styleDict, prop))))); }, keys(styleDict)); }); };
// render-dom-portal
var renderDomPortal = function(args, env, ns) { return (function() {
var selector = trampoline(evalExpr(first(args), env));
@@ -1781,6 +1772,39 @@ return (isSxTruthy(testFn()) ? (function() {
})());
})(); };
// render-dom-error-boundary
var renderDomErrorBoundary = function(args, env, ns) { return (function() {
var fallbackExpr = first(args);
var bodyExprs = rest(args);
var container = domCreateElement("div", NIL);
var boundaryDisposers = [];
domSetAttr(container, "data-sx-boundary", "true");
return (function() {
var renderBody = function() { { var _c = boundaryDisposers; for (var _i = 0; _i < _c.length; _i++) { var d = _c[_i]; d(); } }
boundaryDisposers = [];
domSetProp(container, "innerHTML", "");
return tryCatch(function() { return withIslandScope(function(disposable) { boundaryDisposers.push(disposable);
return registerInScope(disposable); }, function() { return (function() {
var frag = createFragment();
{ var _c = bodyExprs; for (var _i = 0; _i < _c.length; _i++) { var child = _c[_i]; domAppend(frag, renderToDom(child, env, ns)); } }
return domAppend(container, frag);
})(); }); }, function(err) { { var _c = boundaryDisposers; for (var _i = 0; _i < _c.length; _i++) { var d = _c[_i]; d(); } }
boundaryDisposers = [];
return (function() {
var fallbackFn = trampoline(evalExpr(fallbackExpr, env));
var retryFn = function() { return renderBody(); };
return (function() {
var fallbackDom = (isSxTruthy(isLambda(fallbackFn)) ? renderLambdaDom(fallbackFn, [err, retryFn], env, ns) : renderToDom(apply(fallbackFn, [err, retryFn]), env, ns));
return domAppend(container, fallbackDom);
})();
})(); }); };
renderBody();
registerInScope(function() { { var _c = boundaryDisposers; for (var _i = 0; _i < _c.length; _i++) { var d = _c[_i]; d(); } }
return (boundaryDisposers = []); });
return container;
})();
})(); };
// === Transpiled from engine ===
@@ -3083,6 +3107,13 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() {
return remove;
})(); }); };
// resource
var resource = function(fetchFn) { return (function() {
var state = signal({["loading"]: true, ["data"]: NIL, ["error"]: NIL});
promiseThen(invoke(fetchFn), function(data) { return reset_b(state, {["loading"]: false, ["data"]: data, ["error"]: NIL}); }, function(err) { return reset_b(state, {["loading"]: false, ["data"]: NIL, ["error"]: err}); });
return state;
})(); };
// =========================================================================
// Platform interface — DOM adapter (browser-only)
@@ -3812,6 +3843,12 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() {
function preventDefault_(e) { if (e && e.preventDefault) e.preventDefault(); }
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); }
}
function errorMessage(e) {
return e && e.message ? e.message : String(e);
}
function elementValue(el) { return el && el.value !== undefined ? el.value : NIL; }
function domAddListener(el, event, fn, opts) {

View File

@@ -185,12 +185,6 @@
;; Two-way input binding: :bind signal
(and (= attr-name "bind") (signal? attr-val))
(bind-input el attr-val)
;; class-map: reactively toggle classes
(= attr-name "class-map")
(reactive-class-map el attr-val)
;; style-map: reactively set inline styles
(= attr-name "style-map")
(reactive-style-map el attr-val)
;; ref: set ref.current to this element
(= attr-name "ref")
(ref-set! attr-val el)
@@ -315,7 +309,7 @@
(define RENDER_DOM_FORMS
(list "if" "when" "cond" "case" "let" "let*" "begin" "do"
"define" "defcomp" "defisland" "defmacro" "defstyle" "defhandler"
"map" "map-indexed" "filter" "for-each" "portal"))
"map" "map-indexed" "filter" "for-each" "portal" "error-boundary"))
(define render-dom-form?
(fn (name)
@@ -436,6 +430,10 @@
(= name "portal")
(render-dom-portal args env ns)
;; error-boundary — catch errors, render fallback
(= name "error-boundary")
(render-dom-error-boundary args env ns)
;; for-each (render variant)
(= name "for-each")
(let ((f (trampoline (eval-expr (nth expr 1) env)))
@@ -713,48 +711,6 @@
(reset! sig (dom-get-prop el "value"))))))))
;; --------------------------------------------------------------------------
;; reactive-class-map — toggle classes based on signals
;; --------------------------------------------------------------------------
;;
;; Dict values should be signals or booleans. Signals are deref'd reactively.
;;
;; (div :class-map (dict "active" selected? "hidden" hide-computed))
;;
;; Creates a single effect that deref's each value (tracking signal deps)
;; and calls classList.add/remove for each class.
(define reactive-class-map
(fn (el class-dict)
(effect (fn ()
(for-each
(fn (cls)
(let ((val (deref (get class-dict cls))))
(if val
(dom-add-class el cls)
(dom-remove-class el cls))))
(keys class-dict))))))
;; --------------------------------------------------------------------------
;; reactive-style-map — reactively set inline styles via signals
;; --------------------------------------------------------------------------
;;
;; Dict values should be signals or strings. Signals are deref'd reactively.
;;
;; (div :style-map (dict "width" width-sig "opacity" opacity-computed))
;;
;; Creates a single effect that deref's each value and sets the style property.
(define reactive-style-map
(fn (el style-dict)
(effect (fn ()
(for-each
(fn (prop)
(dom-set-style el prop (str (deref (get style-dict prop)))))
(keys style-dict))))))
;; --------------------------------------------------------------------------
;; render-dom-portal — render children into a remote target element
;; --------------------------------------------------------------------------
@@ -793,6 +749,80 @@
marker)))))
;; --------------------------------------------------------------------------
;; render-dom-error-boundary — catch errors, render fallback UI
;; --------------------------------------------------------------------------
;;
;; (error-boundary fallback-fn body...)
;;
;; Renders body children inside a try/catch. If any child throws during
;; rendering, the fallback function is called with the error object, and
;; its result is rendered instead. Effects within the boundary are disposed
;; on error.
;;
;; The fallback function receives the error and a retry thunk:
;; (fn (err retry) ...)
;; Calling (retry) re-renders the body, replacing the fallback.
(define render-dom-error-boundary
(fn (args env ns)
(let ((fallback-expr (first args))
(body-exprs (rest args))
(container (dom-create-element "div" nil))
(boundary-disposers (list)))
(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))
;; 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))
;; 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))))
;; --------------------------------------------------------------------------
;; Platform interface — DOM adapter
;; --------------------------------------------------------------------------
@@ -821,13 +851,6 @@
;; (dom-set-prop el name val) → void (set JS property: el[name] = val)
;; (dom-get-prop el name) → any (read JS property: el[name])
;;
;; Class manipulation (for reactive class-map):
;; (dom-add-class el cls) → void (classList.add)
;; (dom-remove-class el cls) → void (classList.remove)
;;
;; Style manipulation (for reactive style-map):
;; (dom-set-style el prop val) → void (el.style[prop] = val)
;;
;; Query (for portals):
;; (dom-query selector) → Element or nil (document.querySelector)
;;

View File

@@ -411,6 +411,7 @@ class JSEmitter:
"engine-init": "engineInit",
# engine orchestration platform
"promise-resolve": "promiseResolve",
"promise-then": "promiseThen",
"promise-catch": "promiseCatch",
"abort-previous": "abortPrevious",
"track-controller": "trackController",
@@ -446,6 +447,8 @@ class JSEmitter:
"prevent-default": "preventDefault_",
"stop-propagation": "stopPropagation_",
"dom-focus": "domFocus",
"try-catch": "tryCatch",
"error-message": "errorMessage",
"element-value": "elementValue",
"validate-for-request": "validateForRequest",
"with-transition": "withTransition",
@@ -3465,6 +3468,12 @@ PLATFORM_ORCHESTRATION_JS = """
function preventDefault_(e) { if (e && e.preventDefault) e.preventDefault(); }
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); }
}
function errorMessage(e) {
return e && e.message ? e.message : String(e);
}
function elementValue(el) { return el && el.value !== undefined ? el.value : NIL; }
function domAddListener(el, event, fn, opts) {

View File

@@ -408,3 +408,33 @@
(reset! target-signal new-val))))))
;; Return cleanup — removes listener on dispose/re-run
remove)))))
;; ==========================================================================
;; 15. Resource — async signal with loading/resolved/error states
;; ==========================================================================
;;
;; A resource wraps an async operation (fetch, computation) and exposes
;; its state as a signal. The signal transitions through:
;; {:loading true :data nil :error nil} — initial/loading
;; {:loading false :data result :error nil} — success
;; {:loading false :data nil :error err} — failure
;;
;; Usage:
;; (let ((user (resource (fn () (fetch-json "/api/user")))))
;; (cond
;; (get (deref user) "loading") (div "Loading...")
;; (get (deref user) "error") (div "Error: " (get (deref user) "error"))
;; :else (div (get (deref user) "data"))))
;;
;; Platform interface required:
;; (promise-then promise on-resolve on-reject) → void
(define resource
(fn (fetch-fn)
(let ((state (signal (dict "loading" true "data" nil "error" nil))))
;; Kick off the async operation
(promise-then (invoke fetch-fn)
(fn (data) (reset! state (dict "loading" false "data" data "error" nil)))
(fn (err) (reset! state (dict "loading" false "data" nil "error" err))))
state)))