From 06adbdcd596e4e535783f6e53c796523435788e3 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 8 Mar 2026 16:54:40 +0000 Subject: [PATCH] Remove redundant features: ref sugar, suspense, transitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ref/ref-get/ref-set! functions removed (just dict wrappers — use dict primitives directly). The :ref attribute stays in adapter-dom.sx. - Suspense form removed (if/when + deref on resource signals covers it) - Transition function removed (fine-grained signals already avoid jank) - Kept: error-boundary, resource, portal, :ref attribute Co-Authored-By: Claude Opus 4.6 --- shared/static/scripts/sx-browser.js | 48 ++------------- shared/static/scripts/sx-ref.js | 48 ++------------- shared/sx/ref/adapter-dom.sx | 69 +-------------------- shared/sx/ref/signals.sx | 49 +-------------- sx/sx/reactive-islands.sx | 95 ++++++++--------------------- 5 files changed, 39 insertions(+), 270 deletions(-) diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js index 0ee7245..a87c1a7 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:36:52Z"; + var SX_VERSION = "2026-03-08T16:54:18Z"; 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 == "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")) ? dictSet(attrVal, "current", 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", "error-boundary", "suspense"]; + 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 == "error-boundary")) ? renderDomErrorBoundary(args, env, ns) : (isSxTruthy((name == "suspense")) ? renderDomSuspense(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() { @@ -1805,30 +1805,6 @@ 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 === @@ -3088,15 +3064,6 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() { // register-in-scope var registerInScope = function(disposable) { return (isSxTruthy(_islandScope) ? _islandScope(disposable) : NIL); }; - // ref - var ref = function(initial) { return {["current"]: initial}; }; - - // ref-get - var refGet = function(r) { return get(r, "current"); }; - - // ref-set! - var refSet_b = function(r, v) { return dictSet(r, "current", v); }; - // *store-registry* var _storeRegistry = {}; @@ -3138,11 +3105,6 @@ 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) diff --git a/shared/static/scripts/sx-ref.js b/shared/static/scripts/sx-ref.js index 0ee7245..a87c1a7 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:36:52Z"; + var SX_VERSION = "2026-03-08T16:54:18Z"; 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 == "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")) ? dictSet(attrVal, "current", 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", "error-boundary", "suspense"]; + 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 == "error-boundary")) ? renderDomErrorBoundary(args, env, ns) : (isSxTruthy((name == "suspense")) ? renderDomSuspense(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() { @@ -1805,30 +1805,6 @@ 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 === @@ -3088,15 +3064,6 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() { // register-in-scope var registerInScope = function(disposable) { return (isSxTruthy(_islandScope) ? _islandScope(disposable) : NIL); }; - // ref - var ref = function(initial) { return {["current"]: initial}; }; - - // ref-get - var refGet = function(r) { return get(r, "current"); }; - - // ref-set! - var refSet_b = function(r, v) { return dictSet(r, "current", v); }; - // *store-registry* var _storeRegistry = {}; @@ -3138,11 +3105,6 @@ 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) diff --git a/shared/sx/ref/adapter-dom.sx b/shared/sx/ref/adapter-dom.sx index 6d5c951..6c13ce1 100644 --- a/shared/sx/ref/adapter-dom.sx +++ b/shared/sx/ref/adapter-dom.sx @@ -187,7 +187,7 @@ (bind-input el attr-val) ;; ref: set ref.current to this element (= attr-name "ref") - (ref-set! attr-val el) + (dict-set! attr-val "current" el) ;; Boolean attr (contains? BOOLEAN_ATTRS attr-name) (when attr-val (dom-set-attr el attr-name "")) @@ -310,7 +310,7 @@ (list "if" "when" "cond" "case" "let" "let*" "begin" "do" "define" "defcomp" "defisland" "defmacro" "defstyle" "defhandler" "map" "map-indexed" "filter" "for-each" "portal" - "error-boundary" "suspense")) + "error-boundary")) (define render-dom-form? (fn (name) @@ -435,10 +435,6 @@ (= 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))) @@ -828,66 +824,6 @@ 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 ;; -------------------------------------------------------------------------- @@ -944,7 +880,6 @@ ;; From signals.sx: ;; signal, deref, reset!, swap!, computed, effect, batch ;; signal?, with-island-scope, register-in-scope -;; ref, ref-get, ref-set! ;; ;; Pure primitives used: ;; keys, get, str diff --git a/shared/sx/ref/signals.sx b/shared/sx/ref/signals.sx index 6a3b1dd..40fb25a 100644 --- a/shared/sx/ref/signals.sx +++ b/shared/sx/ref/signals.sx @@ -306,28 +306,7 @@ ;; ========================================================================== -;; 12. Refs — mutable boxes, no reactivity -;; ========================================================================== -;; -;; A ref is a mutable container that does NOT trigger subscriptions when -;; written. Like React's useRef: holds mutable values between renders, and -;; provides imperative DOM element access via :ref attribute. - -(define ref - (fn (initial) - (dict "current" initial))) - -(define ref-get - (fn (r) - (get r "current"))) - -(define ref-set! - (fn (r v) - (dict-set! r "current" v))) - - -;; ========================================================================== -;; 13. Named stores — page-level signal containers (L3) +;; 12. Named stores — page-level signal containers (L3) ;; ========================================================================== ;; ;; Stores persist across island creation/destruction. They live at page @@ -411,7 +390,7 @@ ;; ========================================================================== -;; 15. Resource — async signal with loading/resolved/error states +;; 14. Resource — async signal with loading/resolved/error states ;; ========================================================================== ;; ;; A resource wraps an async operation (fetch, computation) and exposes @@ -440,27 +419,3 @@ 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 bea42e0..a90fabf 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" "Refs") - (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: ref, ref-get, ref-set!, :ref attr")) (tr :class "border-b border-stone-100" (td :class "px-3 py-2 text-stone-700" "Portals") (td :class "px-3 py-2 text-green-700 font-medium" "Done") @@ -155,13 +151,13 @@ (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")) + (td :class "px-3 py-2 text-stone-700" "Suspense") + (td :class "px-3 py-2 text-stone-500 font-medium" "N/A") + (td :class "px-3 py-2 font-mono text-xs text-stone-500" "covered by existing primitives")) (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")))))))) + (td :class "px-3 py-2 text-stone-500 font-medium" "N/A") + (td :class "px-3 py-2 font-mono text-xs text-stone-500" "covered by existing primitives")))))))) ;; --------------------------------------------------------------------------- ;; Live demo islands @@ -308,27 +304,7 @@ (p :class "text-sm text-green-700" "Thanks for agreeing!"))))) -;; 7. Refs — mutable boxes + DOM element access -(defisland ~demo-refs () - (let ((input-ref (ref nil)) - (count (signal 0))) - (div :class "rounded border border-violet-200 bg-violet-50 p-4 my-4 space-y-3" - (div :class "flex items-center gap-3" - (input :type "text" :ref input-ref - :placeholder "Focus me with the button..." - :class "px-3 py-1.5 rounded border border-stone-300 text-sm focus:outline-none focus:border-violet-400 w-48") - (button :class "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700" - :on-click (fn (e) - (do - (dom-focus (ref-get input-ref)) - (swap! count inc))) - "Focus Input") - (span :class "text-sm text-stone-500" - "Focused " (deref count) " times")) - (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.")))) - -;; 8. Portal — render into a remote DOM target +;; 7. 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" @@ -396,30 +372,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. 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 "8. Portals" :id "demo-portal" + (~doc-section :title "7. 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 "9. How defisland Works" :id "how-defisland" + (~doc-section :title "8. 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 "10. Test suite" :id "demo-tests" + (~doc-section :title "9. 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"))) (~doc-section :title "What's next" :id "next" - (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 "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, portals, error boundaries, and resource.") (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.")))) @@ -665,17 +635,21 @@ (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" "Refs + portals") + (td :class "px-3 py-2 text-stone-700" "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")) + (td :class "px-3 py-2 text-stone-700" "adapter-dom.sx: portal render-dom form")) (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-stone-700" "Error boundaries") (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")) + (td :class "px-3 py-2 text-stone-700" "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") + (td :class "px-3 py-2 text-stone-500 font-medium" "N/A") + (td :class "px-3 py-2 text-stone-700" "covered by existing primitives")) (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 text-stone-700" "signals.sx: transition, schedule-idle")))))) + (td :class "px-3 py-2 text-stone-500 font-medium" "N/A") + (td :class "px-3 py-2 text-stone-700" "covered by existing primitives")))))) (~doc-section :title "Design Principles" :id "principles" (ol :class "space-y-3 text-stone-600 list-decimal list-inside" @@ -718,11 +692,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" "Refs") - (td :class "px-3 py-2 text-stone-500 text-xs" "useRef") - (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")) (tr :class "border-b border-stone-100" (td :class "px-3 py-2 text-stone-700" "Portals") (td :class "px-3 py-2 text-stone-500 text-xs" "createPortal") @@ -736,13 +705,13 @@ (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-green-700 font-medium" "Done") - (td :class "px-3 py-2 font-mono text-xs text-stone-500" "adapter-dom.sx, signals.sx")) + (td :class "px-3 py-2 text-stone-500 font-medium" "N/A") + (td :class "px-3 py-2 font-mono text-xs text-stone-500" "covered by existing primitives")) (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-green-700 font-medium" "Done") - (td :class "px-3 py-2 font-mono text-xs text-stone-500" "signals.sx")))))) + (td :class "px-3 py-2 text-stone-500 font-medium" "N/A") + (td :class "px-3 py-2 font-mono text-xs text-stone-500" "covered by existing primitives")))))) ;; ----------------------------------------------------------------------- ;; P0 — must have @@ -802,17 +771,6 @@ ;; P1 — important ;; ----------------------------------------------------------------------- - (~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.") - - (~doc-subsection :title "Design" - (~doc-code :code (highlight ";; ref — mutable box, no reactivity\n(define ref\n (fn (initial)\n (dict \"current\" initial)))\n\n(define ref-get (fn (r) (get r \"current\")))\n(define ref-set! (fn (r v) (dict-set! r \"current\" v)))\n\n;; Usage: holding mutable state without triggering effects\n(defisland ~canvas-demo ()\n (let ((frame-id (ref nil))\n (ctx (ref nil)))\n ;; Attach to canvas element via :ref\n (canvas :ref ctx :width 400 :height 300)\n ;; frame-id changes don't trigger re-renders\n (effect (fn ()\n (when (ref-get ctx)\n (draw-frame (ref-get ctx))\n (ref-set! frame-id\n (request-animation-frame\n (fn () (draw-frame (ref-get ctx))))))))))" "lisp")) - - (p "Two features:") - (ol :class "space-y-1 text-stone-600 list-decimal list-inside text-sm" - (li (strong "Mutable box: ") (code "ref") " / " (code "ref-get") " / " (code "ref-set!") " — trivial, just a dict wrapper. Spec in " (code "signals.sx") ".") - (li (strong "DOM ref attribute: ") (code ":ref") " on an element sets " (code "ref.current") " to the DOM node after rendering. Spec in " (code "adapter-dom.sx") " — during element creation, if " (code ":ref") " is present, call " (code "(ref-set! r el)") ".")))) - (~doc-section :title "P1: Portals" :id "portals" (p "A portal renders children into a DOM node " (em "outside") " the island's subtree. Essential for modals, tooltips, dropdown menus, and toast notifications — anything that must escape overflow:hidden, z-index stacking, or layout constraints.") @@ -880,11 +838,8 @@ (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 "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.") - (li (strong "Suspense + resource") " (P2) — new signal variant + render-dom form. Needs promise platform primitives. Builds on error boundaries for the error case.") - (li (strong "Transitions") " (P2) — scheduling primitive + signal batching variant. Lowest priority — SX's fine-grained model already avoids most jank.")) + (li (strong "Error boundaries") " (P2) — one new render-dom form with try/catch. Independent of everything else.")) (p :class "mt-4 text-stone-600" "Every feature follows the same pattern: spec in " (code ".sx") " → bootstrap to JS/Python → add platform primitives → add demo island. No feature requires changes to the signal runtime, the evaluator, or the rendering pipeline. They are all additive."))