diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js index b4e8411..45e7766 100644 --- a/shared/static/scripts/sx-browser.js +++ b/shared/static/scripts/sx-browser.js @@ -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) { diff --git a/shared/static/scripts/sx-ref.js b/shared/static/scripts/sx-ref.js index b4e8411..45e7766 100644 --- a/shared/static/scripts/sx-ref.js +++ b/shared/static/scripts/sx-ref.js @@ -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) { diff --git a/shared/sx/ref/adapter-dom.sx b/shared/sx/ref/adapter-dom.sx index 08fc2d1..3992501 100644 --- a/shared/sx/ref/adapter-dom.sx +++ b/shared/sx/ref/adapter-dom.sx @@ -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) ;; diff --git a/shared/sx/ref/bootstrap_js.py b/shared/sx/ref/bootstrap_js.py index 1a0cc35..b7fe518 100644 --- a/shared/sx/ref/bootstrap_js.py +++ b/shared/sx/ref/bootstrap_js.py @@ -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) { diff --git a/shared/sx/ref/signals.sx b/shared/sx/ref/signals.sx index 07cfd40..703522e 100644 --- a/shared/sx/ref/signals.sx +++ b/shared/sx/ref/signals.sx @@ -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))) diff --git a/sx/sx/reactive-islands.sx b/sx/sx/reactive-islands.sx index d23f27b..9047096 100644 --- a/sx/sx/reactive-islands.sx +++ b/sx/sx/reactive-islands.sx @@ -137,10 +137,6 @@ (td :class "px-3 py-2 text-stone-700" "Keyed reconciliation") (td :class "px-3 py-2 text-green-700 font-medium" "Done") (td :class "px-3 py-2 font-mono text-xs text-stone-500" "adapter-dom.sx: :key attr, extract-key")) - (tr :class "border-b border-stone-100" - (td :class "px-3 py-2 text-stone-700" "Reactive class/style") - (td :class "px-3 py-2 text-green-700 font-medium" "Done") - (td :class "px-3 py-2 font-mono text-xs text-stone-500" "adapter-dom.sx: :class-map, :style-map")) (tr :class "border-b border-stone-100" (td :class "px-3 py-2 text-stone-700" "Refs") (td :class "px-3 py-2 text-green-700 font-medium" "Done") @@ -300,35 +296,7 @@ (p :class "text-sm text-green-700" "Thanks for agreeing!"))))) -;; 7. Reactive class/style — toggle classes and styles from signals -(defisland ~demo-class-map () - (let ((active (signal false)) - (size (signal 100))) - (div :class "rounded border border-violet-200 bg-violet-50 p-4 my-4 space-y-3" - (div :class "flex items-center gap-3" - (button :class "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700" - :on-click (fn (e) (swap! active not)) - "Toggle Active") - (button :class "px-3 py-1 rounded bg-stone-300 text-stone-700 text-sm hover:bg-stone-400" - :on-click (fn (e) (swap! size (fn (s) (+ s 20)))) - "Grow") - (button :class "px-3 py-1 rounded bg-stone-300 text-stone-700 text-sm hover:bg-stone-400" - :on-click (fn (e) (swap! size (fn (s) (max 40 (- s 20))))) - "Shrink")) - (div :class "flex justify-center" - (div - :class "rounded flex items-center justify-center font-mono text-sm transition-all duration-300" - :class-map (dict - "bg-violet-600" active - "text-white" active - "bg-stone-200" (computed (fn () (not (deref active)))) - "text-stone-700" (computed (fn () (not (deref active))))) - :style-map (dict - "width" (computed (fn () (str (deref size) "px"))) - "height" (computed (fn () (str (deref size) "px")))) - (if (deref active) "ON" "OFF")))))) - -;; 8. Refs — mutable boxes + DOM element access +;; 7. Refs — mutable boxes + DOM element access (defisland ~demo-refs () (let ((input-ref (ref nil)) (count (signal 0))) @@ -348,7 +316,7 @@ (p :class "text-xs text-stone-400" "The ref holds a mutable reference to the input element. Clicking the button calls focus() imperatively — no signal needed.")))) -;; 9. Portal — render into a remote DOM target +;; 8. Portal — render into a remote DOM target (defisland ~demo-portal () (let ((open? (signal false))) (div :class "rounded border border-violet-200 bg-violet-50 p-4 my-4" @@ -416,30 +384,24 @@ (~doc-code :code (highlight "(defisland ~demo-input-binding ()\n (let ((name (signal \"\"))\n (agreed (signal false)))\n (div\n (input :type \"text\" :bind name\n :placeholder \"Type your name...\")\n (span \"Hello, \" (strong (deref name)) \"!\")\n (input :type \"checkbox\" :bind agreed)\n (when (deref agreed)\n (p \"Thanks for agreeing!\")))))" "lisp")) (p (code ":bind") " detects the element type automatically — text inputs use " (code "value") " + " (code "input") " event, checkboxes use " (code "checked") " + " (code "change") " event. The effect only updates the DOM when the value actually changed, preventing cursor jump.")) - (~doc-section :title "7. Reactive Class/Style" :id "demo-class-map" - (p (code ":class-map") " takes a dict of class names to signals. Each class is toggled reactively via " (code "classList.add/remove") ". " (code ":style-map") " takes a dict of style properties to signals. Both use a single effect that auto-tracks all dependencies.") - (~demo-class-map) - (~doc-code :code (highlight "(defisland ~demo-class-map ()\n (let ((active (signal false))\n (size (signal 100)))\n (div\n (button :on-click (fn (e) (swap! active not))\n \"Toggle Active\")\n (button :on-click (fn (e) (swap! size (fn (s) (+ s 20))))\n \"Grow\")\n (div\n :class-map (dict\n \"bg-violet-600\" active\n \"text-white\" active\n \"bg-stone-200\" (computed (fn () (not (deref active)))))\n :style-map (dict\n \"width\" (computed (fn () (str (deref size) \"px\")))\n \"height\" (computed (fn () (str (deref size) \"px\"))))\n (if (deref active) \"ON\" \"OFF\")))))" "lisp")) - (p "Unlike " (code "reactive-attr") " (which replaces the entire attribute string), " (code "class-map") " uses " (code "classList.toggle") " per class — more efficient and doesn't clobber classes set by CSS transitions or third-party scripts.")) - - (~doc-section :title "8. Refs" :id "demo-refs" + (~doc-section :title "7. Refs" :id "demo-refs" (p "A " (code "ref") " is a mutable box that does " (em "not") " trigger reactivity. Like React's " (code "useRef") " — holds values between renders and provides imperative DOM access via " (code ":ref") " attribute.") (~demo-refs) (~doc-code :code (highlight "(defisland ~demo-refs ()\n (let ((input-ref (ref nil))\n (count (signal 0)))\n (div\n (input :type \"text\" :ref input-ref\n :placeholder \"Focus me with the button...\")\n (button :on-click (fn (e)\n (do\n (dom-focus (ref-get input-ref))\n (swap! count inc)))\n \"Focus Input\")\n (span \"Focused \" (deref count) \" times\"))))" "lisp")) (p (code ":ref") " on an element sets " (code "ref.current") " to the DOM node after rendering. " (code "ref-get") " and " (code "ref-set!") " are non-reactive — writing to a ref doesn't trigger effects. Use refs for focus management, animations, canvas contexts, and anything requiring imperative DOM access.")) - (~doc-section :title "9. Portals" :id "demo-portal" + (~doc-section :title "8. Portals" :id "demo-portal" (p "A " (code "portal") " renders children into a DOM node " (em "outside") " the island's subtree. Essential for modals, tooltips, and toasts — anything that must escape " (code "overflow:hidden") " or z-index stacking.") (~demo-portal) (~doc-code :code (highlight "(defisland ~demo-portal ()\n (let ((open? (signal false)))\n (div\n (button :on-click (fn (e) (swap! open? not))\n (if (deref open?) \"Close Modal\" \"Open Modal\"))\n (portal \"#portal-root\"\n (when (deref open?)\n (div :class \"fixed inset-0 bg-black/50 ...\"\n :on-click (fn (e) (reset! open? false))\n (div :class \"bg-white rounded-lg p-6 ...\"\n :on-click (fn (e) (stop-propagation e))\n (h2 \"Portal Modal\")\n (p \"Rendered outside the island's DOM.\")\n (button :on-click (fn (e) (reset! open? false))\n \"Close\"))))))))" "lisp")) (p "The portal content lives in " (code "#portal-root") " (typically at the page body level), not inside the island. On island disposal, portal content is automatically removed from its target — the " (code "register-in-scope") " mechanism handles cleanup.")) - (~doc-section :title "10. How defisland Works" :id "how-defisland" + (~doc-section :title "9. How defisland Works" :id "how-defisland" (p (code "defisland") " creates a reactive component. Same calling convention as " (code "defcomp") " — keyword args, rest children — but with a reactive boundary. Inside an island, " (code "deref") " subscribes DOM nodes to signals.") (~doc-code :code (highlight ";; Definition — same syntax as defcomp\n(defisland ~counter (&key initial)\n (let ((count (signal (or initial 0))))\n (div\n (span (deref count)) ;; reactive text node\n (button :on-click (fn (e) (swap! count inc)) ;; event handler\n \"+\"))))\n\n;; Usage — same as any component\n(~counter :initial 42)\n\n;; Server-side rendering:\n;;
\n;; 42\n;;
\n;;\n;; Client hydrates: signals + effects + event handlers attach" "lisp")) (p "Each " (code "deref") " call registers the enclosing DOM node as a subscriber. Signal changes update " (em "only") " the subscribed nodes — no virtual DOM, no diffing, no component re-renders.")) - (~doc-section :title "11. Test suite" :id "demo-tests" + (~doc-section :title "10. Test suite" :id "demo-tests" (p "17 tests verify the signal runtime against the spec. All pass in the Python test runner (which uses the hand-written evaluator with native platform primitives).") (~doc-code :code (highlight ";; Signal basics (6 tests)\n(assert-true (signal? (signal 42)))\n(assert-equal 42 (deref (signal 42)))\n(assert-equal 5 (deref 5)) ;; non-signal passthrough\n\n;; reset! changes value\n(let ((s (signal 0)))\n (reset! s 10)\n (assert-equal 10 (deref s)))\n\n;; reset! does NOT notify when value unchanged (identical? check)\n\n;; Computed (3 tests)\n(let ((a (signal 3)) (b (signal 4))\n (sum (computed (fn () (+ (deref a) (deref b))))))\n (assert-equal 7 (deref sum))\n (reset! a 10)\n (assert-equal 14 (deref sum)))\n\n;; Effects (4 tests) — immediate run, re-run on change, dispose, cleanup\n;; Batch (1 test) — defers notifications, deduplicates subscribers\n;; defisland (3 tests) — creates island, callable, accepts children" "lisp")) (p :class "mt-2 text-sm text-stone-500" "Run: " (code "python3 shared/sx/tests/run.py signals"))) @@ -695,9 +657,9 @@ (td :class "px-3 py-2 text-green-700 font-medium" "Done") (td :class "px-3 py-2 text-stone-700" "adapter-dom.sx: :bind signal, :key attr")) (tr :class "border-b border-stone-100" - (td :class "px-3 py-2 text-stone-700" "Class/style + refs + portals") + (td :class "px-3 py-2 text-stone-700" "Refs + portals") (td :class "px-3 py-2 text-green-700 font-medium" "Done") - (td :class "px-3 py-2 text-stone-700" ":class-map, :style-map, ref, :ref, portal")) + (td :class "px-3 py-2 text-stone-700" "ref, ref-get, ref-set!, :ref, portal")) (tr (td :class "px-3 py-2 text-stone-700" "Phase 2 remaining") (td :class "px-3 py-2 text-violet-700 font-medium" @@ -745,11 +707,6 @@ (td :class "px-3 py-2 text-stone-500 text-xs" "key prop") (td :class "px-3 py-2 text-green-700 font-medium" "Done") (td :class "px-3 py-2 font-mono text-xs text-stone-500" "adapter-dom.sx")) - (tr :class "border-b border-stone-100" - (td :class "px-3 py-2 text-stone-700" "Reactive class/style") - (td :class "px-3 py-2 text-stone-500 text-xs" "className={...}") - (td :class "px-3 py-2 text-green-700 font-medium" "Done") - (td :class "px-3 py-2 font-mono text-xs text-stone-500" "adapter-dom.sx")) (tr :class "border-b border-stone-100" (td :class "px-3 py-2 text-stone-700" "Refs") (td :class "px-3 py-2 text-stone-500 text-xs" "useRef") @@ -834,19 +791,6 @@ ;; P1 — important ;; ----------------------------------------------------------------------- - (~doc-section :title "P1: Reactive Class and Style" :id "reactive-class" - (p "Dynamic CSS classes are the most common form of reactive styling. Currently you can write " (code "(div :class (str \"panel \" (if (deref open?) \"visible\" \"hidden\")))") " and " (code "reactive-attr") " handles it. But this requires string concatenation and full attribute replacement on every change.") - - (~doc-subsection :title "Design" - (p "Two new helpers in " (code "adapter-dom.sx") ":") - - (~doc-code :code (highlight ";; class-map: conditionally toggle classes\n(div :class-map (dict\n \"active\" (deref selected?)\n \"loading\" (deref loading?)\n \"hidden\" (not (deref visible?))))\n;; Generates: effect that adds/removes each class\n;; based on the truthiness of its signal-derived value\n\n;; style-map: reactive inline styles\n(div :style-map (dict\n \"width\" (str (deref progress) \"%\")\n \"opacity\" (if (deref visible?) \"1\" \"0\")\n \"transform\" (str \"translateX(\" (deref x) \"px)\")))\n;; Generates: effect that sets each style property" "lisp")) - - (p "Implementation: during element rendering, detect " (code ":class-map") " / " (code ":style-map") " keywords. For each entry, create a single effect that reads all signal dependencies and updates the element. The effect auto-tracks — only the relevant signals trigger re-evaluation.")) - - (~doc-subsection :title "Why not just reactive-attr?" - (p (code "reactive-attr") " replaces the entire attribute string. For classes, this means every signal change rewrites " (code "className") " — touching all classes even if only one toggled. " (code "class-map") " uses " (code "classList.toggle") " per class, which is more efficient and doesn't clobber classes set by other code (CSS transitions, third-party scripts)."))) - (~doc-section :title "P1: Refs" :id "refs" (p "A ref is a mutable container that does " (em "not") " trigger reactivity when written. React's " (code "useRef") " is used for two things: holding mutable values between renders, and accessing DOM elements imperatively.") @@ -925,7 +869,6 @@ (ol :class "space-y-3 text-stone-600 list-decimal list-inside" (li (strong "Input binding") " (P0) — unlocks forms. Smallest change, biggest impact. One new function in adapter-dom.sx, two platform primitives (" (code "dom-set-prop") ", " (code "dom-get-prop") "). Add to demo page immediately.") (li (strong "Keyed reconciliation") " (P0) — unlocks efficient dynamic lists. Replace reactive-list's effect body. Add " (code ":key") " extraction. No new primitives needed.") - (li (strong "Reactive class/style") " (P1) — quality of life. Two new attr handlers in adapter-dom.sx. Uses existing " (code "classList") " / " (code "style") " DOM APIs.") (li (strong "Refs") " (P1) — trivial: three functions in signals.sx + one attr handler. Unlocks canvas, focus management, animation frame patterns.") (li (strong "Portals") " (P1) — one new render-dom form. Needs disposal integration. Unlocks modals, tooltips, toasts.") (li (strong "Error boundaries") " (P2) — one new render-dom form with try/catch. Independent of everything else.")