diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js index 45e7766..0ee7245 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:35:00Z"; + var SX_VERSION = "2026-03-08T16:36:52Z"; function isNil(x) { return x === NIL || x === null || x === undefined; } function isSxTruthy(x) { return x !== false && !isNil(x); } @@ -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", "error-boundary"]; + 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", "suspense"]; // 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 == "error-boundary")) ? renderDomErrorBoundary(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 == "suspense")) ? renderDomSuspense(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() { @@ -1805,6 +1805,30 @@ return (boundaryDisposers = []); }); })(); })(); }; + // render-dom-suspense + var renderDomSuspense = function(args, env, ns) { return (function() { + var fallbackExpr = first(args); + var bodyExprs = rest(args); + var container = domCreateElement("div", NIL); + domSetAttr(container, "data-sx-suspense", "true"); + domAppend(container, renderToDom(fallbackExpr, env, ns)); + (function() { + var bodyDisposers = []; + effect(function() { { var _c = bodyDisposers; for (var _i = 0; _i < _c.length; _i++) { var d = _c[_i]; d(); } } +bodyDisposers = []; +return tryCatch(function() { return (function() { + var frag = createFragment(); + withIslandScope(function(disposable) { bodyDisposers.push(disposable); +return registerInScope(disposable); }, function() { return forEach(function(child) { return domAppend(frag, renderToDom(child, env, ns)); }, bodyExprs); }); + domSetProp(container, "innerHTML", ""); + return domAppend(container, frag); +})(); }, function(err) { return NIL; }); }); + return registerInScope(function() { { var _c = bodyDisposers; for (var _i = 0; _i < _c.length; _i++) { var d = _c[_i]; d(); } } +return (bodyDisposers = []); }); +})(); + return container; +})(); }; + // === Transpiled from engine === @@ -3114,6 +3138,11 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() { return state; })(); }; + // transition + var transition = function(pendingSig, thunk) { reset_b(pendingSig, true); +return scheduleIdle(function() { batch(thunk); +return reset_b(pendingSig, false); }); }; + // ========================================================================= // Platform interface — DOM adapter (browser-only) @@ -3406,6 +3435,11 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() { function promiseResolve(val) { return Promise.resolve(val); } + function promiseThen(p, onResolve, onReject) { + if (!p || !p.then) return p; + return onReject ? p.then(onResolve, onReject) : p.then(onResolve); + } + function promiseCatch(p, fn) { return p && p.catch ? p.catch(fn) : p; } // --- Abort controllers --- @@ -3849,6 +3883,10 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() { function errorMessage(e) { return e && e.message ? e.message : String(e); } + function scheduleIdle(fn) { + if (typeof requestIdleCallback !== "undefined") requestIdleCallback(fn); + else setTimeout(fn, 0); + } 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 45e7766..0ee7245 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:35:00Z"; + var SX_VERSION = "2026-03-08T16:36:52Z"; function isNil(x) { return x === NIL || x === null || x === undefined; } function isSxTruthy(x) { return x !== false && !isNil(x); } @@ -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", "error-boundary"]; + 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", "suspense"]; // 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 == "error-boundary")) ? renderDomErrorBoundary(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 == "suspense")) ? renderDomSuspense(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() { @@ -1805,6 +1805,30 @@ return (boundaryDisposers = []); }); })(); })(); }; + // render-dom-suspense + var renderDomSuspense = function(args, env, ns) { return (function() { + var fallbackExpr = first(args); + var bodyExprs = rest(args); + var container = domCreateElement("div", NIL); + domSetAttr(container, "data-sx-suspense", "true"); + domAppend(container, renderToDom(fallbackExpr, env, ns)); + (function() { + var bodyDisposers = []; + effect(function() { { var _c = bodyDisposers; for (var _i = 0; _i < _c.length; _i++) { var d = _c[_i]; d(); } } +bodyDisposers = []; +return tryCatch(function() { return (function() { + var frag = createFragment(); + withIslandScope(function(disposable) { bodyDisposers.push(disposable); +return registerInScope(disposable); }, function() { return forEach(function(child) { return domAppend(frag, renderToDom(child, env, ns)); }, bodyExprs); }); + domSetProp(container, "innerHTML", ""); + return domAppend(container, frag); +})(); }, function(err) { return NIL; }); }); + return registerInScope(function() { { var _c = bodyDisposers; for (var _i = 0; _i < _c.length; _i++) { var d = _c[_i]; d(); } } +return (bodyDisposers = []); }); +})(); + return container; +})(); }; + // === Transpiled from engine === @@ -3114,6 +3138,11 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() { return state; })(); }; + // transition + var transition = function(pendingSig, thunk) { reset_b(pendingSig, true); +return scheduleIdle(function() { batch(thunk); +return reset_b(pendingSig, false); }); }; + // ========================================================================= // Platform interface — DOM adapter (browser-only) @@ -3406,6 +3435,11 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() { function promiseResolve(val) { return Promise.resolve(val); } + function promiseThen(p, onResolve, onReject) { + if (!p || !p.then) return p; + return onReject ? p.then(onResolve, onReject) : p.then(onResolve); + } + function promiseCatch(p, fn) { return p && p.catch ? p.catch(fn) : p; } // --- Abort controllers --- @@ -3849,6 +3883,10 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() { function errorMessage(e) { return e && e.message ? e.message : String(e); } + function scheduleIdle(fn) { + if (typeof requestIdleCallback !== "undefined") requestIdleCallback(fn); + else setTimeout(fn, 0); + } 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 3992501..6d5c951 100644 --- a/shared/sx/ref/adapter-dom.sx +++ b/shared/sx/ref/adapter-dom.sx @@ -309,7 +309,8 @@ (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" "error-boundary")) + "map" "map-indexed" "filter" "for-each" "portal" + "error-boundary" "suspense")) (define render-dom-form? (fn (name) @@ -434,6 +435,10 @@ (= name "error-boundary") (render-dom-error-boundary args env ns) + ;; suspense — show fallback while resource is loading + (= name "suspense") + (render-dom-suspense args env ns) + ;; for-each (render variant) (= name "for-each") (let ((f (trampoline (eval-expr (nth expr 1) env))) @@ -823,6 +828,66 @@ container)))) +;; -------------------------------------------------------------------------- +;; render-dom-suspense — show fallback while resource is loading +;; -------------------------------------------------------------------------- +;; +;; (suspense fallback-expr body...) +;; +;; Renders fallback-expr initially. When used with a resource signal, +;; an effect watches the resource state and swaps in the body content +;; once loading is complete. If the resource errors, renders the error. +;; +;; The simplest pattern: wrap a resource deref in suspense. +;; +;; (suspense +;; (div "Loading...") +;; (let ((data (get (deref user-resource) "data"))) +;; (div (get data "name")))) + +(define render-dom-suspense + (fn (args env ns) + (let ((fallback-expr (first args)) + (body-exprs (rest args)) + (container (dom-create-element "div" nil))) + (dom-set-attr container "data-sx-suspense" "true") + ;; Render fallback immediately + (dom-append container (render-to-dom fallback-expr env ns)) + ;; Try to render body — if it works, replace fallback + ;; The body typically derefs a resource signal, which triggers + ;; an effect that re-renders when the resource resolves + (let ((body-disposers (list))) + (effect (fn () + ;; Dispose previous body renders + (for-each (fn (d) (d)) body-disposers) + (set! body-disposers (list)) + ;; Try rendering the body + (try-catch + (fn () + (let ((frag (create-fragment))) + (with-island-scope + (fn (disposable) + (append! body-disposers disposable) + (register-in-scope disposable)) + (fn () + (for-each + (fn (child) + (dom-append frag (render-to-dom child env ns))) + body-exprs))) + ;; Success — replace container content with body + (dom-set-prop container "innerHTML" "") + (dom-append container frag))) + (fn (err) + ;; Body threw — keep showing fallback (or show error) + nil)))) + ;; Register cleanup + (register-in-scope + (fn () + (for-each (fn (d) (d)) body-disposers) + (set! body-disposers (list))))) + container))) + + ;; -------------------------------------------------------------------------- ;; Platform interface — DOM adapter ;; -------------------------------------------------------------------------- diff --git a/shared/sx/ref/bootstrap_js.py b/shared/sx/ref/bootstrap_js.py index b7fe518..f16734d 100644 --- a/shared/sx/ref/bootstrap_js.py +++ b/shared/sx/ref/bootstrap_js.py @@ -449,6 +449,7 @@ class JSEmitter: "dom-focus": "domFocus", "try-catch": "tryCatch", "error-message": "errorMessage", + "schedule-idle": "scheduleIdle", "element-value": "elementValue", "validate-for-request": "validateForRequest", "with-transition": "withTransition", @@ -3031,6 +3032,11 @@ PLATFORM_ORCHESTRATION_JS = """ function promiseResolve(val) { return Promise.resolve(val); } + function promiseThen(p, onResolve, onReject) { + if (!p || !p.then) return p; + return onReject ? p.then(onResolve, onReject) : p.then(onResolve); + } + function promiseCatch(p, fn) { return p && p.catch ? p.catch(fn) : p; } // --- Abort controllers --- @@ -3474,6 +3480,10 @@ PLATFORM_ORCHESTRATION_JS = """ function errorMessage(e) { return e && e.message ? e.message : String(e); } + function scheduleIdle(fn) { + if (typeof requestIdleCallback !== "undefined") requestIdleCallback(fn); + else setTimeout(fn, 0); + } 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 703522e..6a3b1dd 100644 --- a/shared/sx/ref/signals.sx +++ b/shared/sx/ref/signals.sx @@ -438,3 +438,29 @@ (fn (data) (reset! state (dict "loading" false "data" data "error" nil))) (fn (err) (reset! state (dict "loading" false "data" nil "error" err)))) state))) + + +;; ========================================================================== +;; 16. Transitions — non-urgent signal updates +;; ========================================================================== +;; +;; Transitions mark updates as non-urgent. The thunk's signal writes are +;; deferred to an idle callback, keeping the UI responsive during expensive +;; computations. +;; +;; (transition pending-signal thunk) +;; +;; Sets pending-signal to true, schedules thunk via requestIdleCallback +;; (or setTimeout 0 as fallback), then sets pending-signal to false when +;; the thunk completes. Signal writes inside the thunk are batched. +;; +;; Platform interface required: +;; (schedule-idle thunk) → void — requestIdleCallback or setTimeout(fn, 0) + +(define transition + (fn (pending-sig thunk) + (reset! pending-sig true) + (schedule-idle + (fn () + (batch thunk) + (reset! pending-sig false))))) diff --git a/sx/sx/reactive-islands.sx b/sx/sx/reactive-islands.sx index 9047096..bea42e0 100644 --- a/sx/sx/reactive-islands.sx +++ b/sx/sx/reactive-islands.sx @@ -149,7 +149,19 @@ (td :class "px-3 py-2 text-stone-700" "Phase 2 remaining") (td :class "px-3 py-2 text-stone-500 font-medium" "P2") (td :class "px-3 py-2 font-mono text-xs text-stone-500" - (a :href "/reactive-islands/phase2" :sx-get "/reactive-islands/phase2" :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true" :class "text-violet-700 underline" "Error boundaries, suspense, transitions"))))))))) + (a :href "/reactive-islands/phase2" :sx-get "/reactive-islands/phase2" :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true" :class "text-violet-700 underline" "Error boundaries, suspense, transitions"))) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 text-stone-700" "Error boundaries") + (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: error-boundary render-dom form")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 text-stone-700" "Suspense + resource") + (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, signals.sx")) + (tr + (td :class "px-3 py-2 text-stone-700" "Transitions") + (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" "signals.sx: transition, schedule-idle")))))))) ;; --------------------------------------------------------------------------- ;; Live demo islands @@ -407,12 +419,8 @@ (p :class "mt-2 text-sm text-stone-500" "Run: " (code "python3 shared/sx/tests/run.py signals"))) (~doc-section :title "What's next" :id "next" - (p "Phase 1 and Phase 2 P0/P1 features are complete. The remaining P2 features are optional enhancements:") - (ul :class "space-y-2 text-stone-600 list-disc pl-5" - (li (strong "Error boundaries") " — catch errors in island subtrees, render fallback UI") - (li (strong "Suspense + resource") " — async-aware rendering with loading states") - (li (strong "Transitions") " — non-urgent signal updates for expensive re-renders")) - (p "See the " (a :href "/reactive-islands/phase2" :sx-get "/reactive-islands/phase2" :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true" :class "text-violet-700 underline" "Phase 2 plan") " for details.")))) + (p "Phase 1 and Phase 2 are complete. The reactive islands system now includes: signals, effects, computed values, islands, disposal, stores, event bridges, reactive DOM rendering, input binding, keyed reconciliation, refs, portals, error boundaries, suspense, resource, and transitions.") + (p "See the " (a :href "/reactive-islands/phase2" :sx-get "/reactive-islands/phase2" :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true" :class "text-violet-700 underline" "Phase 2 plan") " for the full feature list and design details.")))) ;; --------------------------------------------------------------------------- @@ -660,11 +668,14 @@ (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" "ref, ref-get, ref-set!, :ref, portal")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 text-stone-700" "Error boundaries + suspense") + (td :class "px-3 py-2 text-green-700 font-medium" "Done") + (td :class "px-3 py-2 text-stone-700" "error-boundary, suspense, resource")) (tr - (td :class "px-3 py-2 text-stone-700" "Phase 2 remaining") - (td :class "px-3 py-2 text-violet-700 font-medium" - (a :href "/reactive-islands/phase2" :sx-get "/reactive-islands/phase2" :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true" :class "text-violet-700 underline" "Details →")) - (td :class "px-3 py-2 text-stone-700" "Error boundaries, suspense, transitions")))))) + (td :class "px-3 py-2 text-stone-700" "Transitions") + (td :class "px-3 py-2 text-green-700 font-medium" "Done") + (td :class "px-3 py-2 text-stone-700" "signals.sx: transition, schedule-idle")))))) (~doc-section :title "Design Principles" :id "principles" (ol :class "space-y-3 text-stone-600 list-decimal list-inside" @@ -720,17 +731,17 @@ (tr :class "border-b border-stone-100" (td :class "px-3 py-2 text-stone-700" "Error boundaries") (td :class "px-3 py-2 text-stone-500 text-xs" "componentDidCatch") - (td :class "px-3 py-2 text-stone-500 font-medium" "P2") + (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" "Suspense") (td :class "px-3 py-2 text-stone-500 text-xs" "Suspense + lazy") - (td :class "px-3 py-2 text-stone-500 font-medium" "P2") + (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, signals.sx")) (tr (td :class "px-3 py-2 text-stone-700" "Transitions") (td :class "px-3 py-2 text-stone-500 text-xs" "startTransition") - (td :class "px-3 py-2 text-stone-500 font-medium" "P2") + (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" "signals.sx")))))) ;; -----------------------------------------------------------------------