diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js index 812b8e2..6ca3b4d 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-08T15:15:32Z"; + var SX_VERSION = "2026-03-08T16:03:46Z"; 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(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(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); @@ -1572,6 +1572,20 @@ return result; }, args); { var _c = range(1, len(expr)); for (var _i = 0; _i < _c.length; _i++) { var i = _c[_i]; domAppend(frag, renderToDom(nth(expr, i), env, ns)); } } return frag; })() : (isSxTruthy(isDefinitionForm(name)) ? (trampoline(evalExpr(expr, env)), createFragment()) : (isSxTruthy((name == "map")) ? (function() { + var collExpr = nth(expr, 2); + return (isSxTruthy((isSxTruthy(_islandScope) && isSxTruthy((typeOf(collExpr) == "list")) && isSxTruthy((len(collExpr) > 1)) && (first(collExpr) == "deref"))) ? (function() { + var f = trampoline(evalExpr(nth(expr, 1), env)); + var sig = trampoline(evalExpr(nth(collExpr, 1), env)); + return (isSxTruthy(isSignal(sig)) ? reactiveList(f, sig, env, ns) : (function() { + var coll = deref(sig); + var frag = createFragment(); + { var _c = coll; for (var _i = 0; _i < _c.length; _i++) { var item = _c[_i]; (function() { + var val = (isSxTruthy(isLambda(f)) ? renderLambdaDom(f, [item], env, ns) : renderToDom(apply(f, [item]), env, ns)); + return domAppend(frag, val); +})(); } } + return frag; +})()); +})() : (function() { var f = trampoline(evalExpr(nth(expr, 1), env)); var coll = trampoline(evalExpr(nth(expr, 2), env)); var frag = createFragment(); @@ -1580,6 +1594,7 @@ return result; }, args); return domAppend(frag, val); })(); } } return frag; +})()); })() : (isSxTruthy((name == "map-indexed")) ? (function() { var f = trampoline(evalExpr(nth(expr, 1), env)); var coll = trampoline(evalExpr(nth(expr, 2), env)); @@ -1680,15 +1695,29 @@ return (isSxTruthy(testFn()) ? (function() { var parent = domParent(marker); return (isSxTruthy(parent) ? (domRemoveChildrenAfter(marker), (function() { var items = deref(itemsSig); - return forEach(function(item) { return (function() { + var frag = createFragment(); + { var _c = items; for (var _i = 0; _i < _c.length; _i++) { var item = _c[_i]; (function() { var rendered = (isSxTruthy(isLambda(mapFn)) ? renderLambdaDom(mapFn, [item], env, ns) : renderToDom(apply(mapFn, [item]), env, ns)); - return domInsertAfter(marker, rendered); -})(); }, reverse(items)); + return domAppend(frag, rendered); +})(); } } + return domInsertAfter(marker, frag); })()) : NIL); })(); }); return container; })(); }; + // bind-input + var bindInput = function(el, sig) { return (function() { + var inputType = lower(sxOr(domGetAttr(el, "type"), "")); + var isCheckbox = sxOr((inputType == "checkbox"), (inputType == "radio")); + (isSxTruthy(isCheckbox) ? domSetProp(el, "checked", deref(sig)) : domSetProp(el, "value", (String(deref(sig))))); + effect(function() { return (isSxTruthy(isCheckbox) ? domSetProp(el, "checked", deref(sig)) : (function() { + var v = (String(deref(sig))); + return (isSxTruthy((domGetProp(el, "value") != v)) ? domSetProp(el, "value", v) : NIL); +})()); }); + return domListen(el, (isSxTruthy(isCheckbox) ? "change" : "input"), function(e) { return (isSxTruthy(isCheckbox) ? reset_b(sig, domGetProp(el, "checked")) : reset_b(sig, domGetProp(el, "value"))); }); +})(); }; + // === Transpiled from engine === @@ -2049,12 +2078,14 @@ return forEach(function(attr) { return (isSxTruthy(!isSxTruthy(domHasAttr(newEl, var rendered = sxRender(trimmed); var container = domCreateElement("div", NIL); domAppend(container, rendered); - processOobSwaps(container, function(t, oob, s) { swapDomNodes(t, oob, s); + processOobSwaps(container, function(t, oob, s) { disposeIslandsIn(t); +swapDomNodes(t, oob, s); sxHydrate(t); return processElements(t); }); return (function() { var selectSel = domGetAttr(el, "sx-select"); var content = (isSxTruthy(selectSel) ? selectFromContainer(container, selectSel) : childrenToFragment(container)); + disposeIslandsIn(target); return withTransition(useTransition, function() { swapDomNodes(target, content, swapStyle); return postSwap(target); }); })(); @@ -2068,6 +2099,7 @@ return postSwap(target); }); var doc = domParseHtmlDocument(text); return (isSxTruthy(doc) ? (function() { var selectSel = domGetAttr(el, "sx-select"); + disposeIslandsIn(target); return (isSxTruthy(selectSel) ? (function() { var html = selectHtmlFromDoc(doc, selectSel); return withTransition(useTransition, function() { swapHtmlString(target, html, swapStyle); @@ -2075,7 +2107,8 @@ return postSwap(target); }); })() : (function() { var container = domCreateElement("div", NIL); domSetInnerHtml(container, domBodyInnerHtml(doc)); - processOobSwaps(container, function(t, oob, s) { swapDomNodes(t, oob, s); + processOobSwaps(container, function(t, oob, s) { disposeIslandsIn(t); +swapDomNodes(t, oob, s); return postSwap(t); }); hoistHeadElements(container); return withTransition(useTransition, function() { swapDomNodes(target, childrenToFragment(container), swapStyle); @@ -2363,7 +2396,7 @@ return logWarn((String("sx:offline sync failed ") + String(get(entry, "action")) })(); }; // swap-rendered-content - var swapRenderedContent = function(target, rendered, pathname) { return (domSetTextContent(target, ""), domAppend(target, rendered), hoistHeadElementsFull(target), processElements(target), sxHydrateElements(target), domDispatch(target, "sx:clientRoute", {["pathname"]: pathname}), logInfo((String("sx:route client ") + String(pathname)))); }; + var swapRenderedContent = function(target, rendered, pathname) { return (disposeIslandsIn(target), domSetTextContent(target, ""), domAppend(target, rendered), hoistHeadElementsFull(target), processElements(target), sxHydrateElements(target), domDispatch(target, "sx:clientRoute", {["pathname"]: pathname}), logInfo((String("sx:route client ") + String(pathname)))); }; // resolve-route-target var resolveRouteTarget = function(targetSel) { return (isSxTruthy((isSxTruthy(targetSel) && !isSxTruthy((targetSel == "true")))) ? domQuery(targetSel) : NIL); }; @@ -2453,14 +2486,14 @@ return (function() { var swapStyle = get(swapSpec, "style"); var useTransition = get(swapSpec, "transition"); var trimmed = trim(data); - return (isSxTruthy(!isSxTruthy(isEmpty(trimmed))) ? (isSxTruthy(startsWith(trimmed, "(")) ? (function() { + return (isSxTruthy(!isSxTruthy(isEmpty(trimmed))) ? (disposeIslandsIn(target), (isSxTruthy(startsWith(trimmed, "(")) ? (function() { var rendered = sxRender(trimmed); var container = domCreateElement("div", NIL); domAppend(container, rendered); return withTransition(useTransition, function() { swapDomNodes(target, childrenToFragment(container), swapStyle); return postSwap(target); }); })() : withTransition(useTransition, function() { swapHtmlString(target, trimmed, swapStyle); -return postSwap(target); })) : NIL); +return postSwap(target); }))) : NIL); })(); }; // bind-inline-handlers @@ -2722,6 +2755,12 @@ callExpr.push(dictGet(kwargs, k)); } } return (isSxTruthy(disposers) ? (forEach(function(d) { return (isSxTruthy(isCallable(d)) ? d() : NIL); }, disposers), domSetData(el, "sx-disposers", NIL)) : NIL); })(); }; + // dispose-islands-in + var disposeIslandsIn = function(root) { return (isSxTruthy(root) ? (function() { + var islands = domQueryAll(root, "[data-sx-island]"); + return (isSxTruthy((isSxTruthy(islands) && !isSxTruthy(isEmpty(islands)))) ? (logInfo((String("disposing ") + String(len(islands)) + String(" island(s)"))), forEach(disposeIsland, islands)) : NIL); +})() : NIL); }; + // boot-init var bootInit = function() { return (logInfo((String("sx-browser ") + String(SX_VERSION))), initCssTracking(), processPageScripts(), processSxScripts(NIL), sxHydrateElements(NIL), sxHydrateIslands(NIL), processElements(NIL)); }; @@ -2850,6 +2889,7 @@ return (function() { })(); })(); }; recompute(); + registerInScope(function() { return disposeComputed(s); }); return s; })(); })(); }; @@ -2874,12 +2914,16 @@ return (function() { })(); })()) : NIL); }; runEffect(); - return function() { disposed = true; + return (function() { + var disposeFn = function() { disposed = true; if (isSxTruthy(cleanupFn)) { invoke(cleanupFn); } { var _c = deps; for (var _i = 0; _i < _c.length; _i++) { var dep = _c[_i]; signalRemoveSub(dep, runEffect); } } return (deps = []); }; + registerInScope(disposeFn); + return disposeFn; +})(); })(); })(); }; diff --git a/shared/static/scripts/sx-ref.js b/shared/static/scripts/sx-ref.js index 812b8e2..6ca3b4d 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-08T15:15:32Z"; + var SX_VERSION = "2026-03-08T16:03:46Z"; 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(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(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); @@ -1572,6 +1572,20 @@ return result; }, args); { var _c = range(1, len(expr)); for (var _i = 0; _i < _c.length; _i++) { var i = _c[_i]; domAppend(frag, renderToDom(nth(expr, i), env, ns)); } } return frag; })() : (isSxTruthy(isDefinitionForm(name)) ? (trampoline(evalExpr(expr, env)), createFragment()) : (isSxTruthy((name == "map")) ? (function() { + var collExpr = nth(expr, 2); + return (isSxTruthy((isSxTruthy(_islandScope) && isSxTruthy((typeOf(collExpr) == "list")) && isSxTruthy((len(collExpr) > 1)) && (first(collExpr) == "deref"))) ? (function() { + var f = trampoline(evalExpr(nth(expr, 1), env)); + var sig = trampoline(evalExpr(nth(collExpr, 1), env)); + return (isSxTruthy(isSignal(sig)) ? reactiveList(f, sig, env, ns) : (function() { + var coll = deref(sig); + var frag = createFragment(); + { var _c = coll; for (var _i = 0; _i < _c.length; _i++) { var item = _c[_i]; (function() { + var val = (isSxTruthy(isLambda(f)) ? renderLambdaDom(f, [item], env, ns) : renderToDom(apply(f, [item]), env, ns)); + return domAppend(frag, val); +})(); } } + return frag; +})()); +})() : (function() { var f = trampoline(evalExpr(nth(expr, 1), env)); var coll = trampoline(evalExpr(nth(expr, 2), env)); var frag = createFragment(); @@ -1580,6 +1594,7 @@ return result; }, args); return domAppend(frag, val); })(); } } return frag; +})()); })() : (isSxTruthy((name == "map-indexed")) ? (function() { var f = trampoline(evalExpr(nth(expr, 1), env)); var coll = trampoline(evalExpr(nth(expr, 2), env)); @@ -1680,15 +1695,29 @@ return (isSxTruthy(testFn()) ? (function() { var parent = domParent(marker); return (isSxTruthy(parent) ? (domRemoveChildrenAfter(marker), (function() { var items = deref(itemsSig); - return forEach(function(item) { return (function() { + var frag = createFragment(); + { var _c = items; for (var _i = 0; _i < _c.length; _i++) { var item = _c[_i]; (function() { var rendered = (isSxTruthy(isLambda(mapFn)) ? renderLambdaDom(mapFn, [item], env, ns) : renderToDom(apply(mapFn, [item]), env, ns)); - return domInsertAfter(marker, rendered); -})(); }, reverse(items)); + return domAppend(frag, rendered); +})(); } } + return domInsertAfter(marker, frag); })()) : NIL); })(); }); return container; })(); }; + // bind-input + var bindInput = function(el, sig) { return (function() { + var inputType = lower(sxOr(domGetAttr(el, "type"), "")); + var isCheckbox = sxOr((inputType == "checkbox"), (inputType == "radio")); + (isSxTruthy(isCheckbox) ? domSetProp(el, "checked", deref(sig)) : domSetProp(el, "value", (String(deref(sig))))); + effect(function() { return (isSxTruthy(isCheckbox) ? domSetProp(el, "checked", deref(sig)) : (function() { + var v = (String(deref(sig))); + return (isSxTruthy((domGetProp(el, "value") != v)) ? domSetProp(el, "value", v) : NIL); +})()); }); + return domListen(el, (isSxTruthy(isCheckbox) ? "change" : "input"), function(e) { return (isSxTruthy(isCheckbox) ? reset_b(sig, domGetProp(el, "checked")) : reset_b(sig, domGetProp(el, "value"))); }); +})(); }; + // === Transpiled from engine === @@ -2049,12 +2078,14 @@ return forEach(function(attr) { return (isSxTruthy(!isSxTruthy(domHasAttr(newEl, var rendered = sxRender(trimmed); var container = domCreateElement("div", NIL); domAppend(container, rendered); - processOobSwaps(container, function(t, oob, s) { swapDomNodes(t, oob, s); + processOobSwaps(container, function(t, oob, s) { disposeIslandsIn(t); +swapDomNodes(t, oob, s); sxHydrate(t); return processElements(t); }); return (function() { var selectSel = domGetAttr(el, "sx-select"); var content = (isSxTruthy(selectSel) ? selectFromContainer(container, selectSel) : childrenToFragment(container)); + disposeIslandsIn(target); return withTransition(useTransition, function() { swapDomNodes(target, content, swapStyle); return postSwap(target); }); })(); @@ -2068,6 +2099,7 @@ return postSwap(target); }); var doc = domParseHtmlDocument(text); return (isSxTruthy(doc) ? (function() { var selectSel = domGetAttr(el, "sx-select"); + disposeIslandsIn(target); return (isSxTruthy(selectSel) ? (function() { var html = selectHtmlFromDoc(doc, selectSel); return withTransition(useTransition, function() { swapHtmlString(target, html, swapStyle); @@ -2075,7 +2107,8 @@ return postSwap(target); }); })() : (function() { var container = domCreateElement("div", NIL); domSetInnerHtml(container, domBodyInnerHtml(doc)); - processOobSwaps(container, function(t, oob, s) { swapDomNodes(t, oob, s); + processOobSwaps(container, function(t, oob, s) { disposeIslandsIn(t); +swapDomNodes(t, oob, s); return postSwap(t); }); hoistHeadElements(container); return withTransition(useTransition, function() { swapDomNodes(target, childrenToFragment(container), swapStyle); @@ -2363,7 +2396,7 @@ return logWarn((String("sx:offline sync failed ") + String(get(entry, "action")) })(); }; // swap-rendered-content - var swapRenderedContent = function(target, rendered, pathname) { return (domSetTextContent(target, ""), domAppend(target, rendered), hoistHeadElementsFull(target), processElements(target), sxHydrateElements(target), domDispatch(target, "sx:clientRoute", {["pathname"]: pathname}), logInfo((String("sx:route client ") + String(pathname)))); }; + var swapRenderedContent = function(target, rendered, pathname) { return (disposeIslandsIn(target), domSetTextContent(target, ""), domAppend(target, rendered), hoistHeadElementsFull(target), processElements(target), sxHydrateElements(target), domDispatch(target, "sx:clientRoute", {["pathname"]: pathname}), logInfo((String("sx:route client ") + String(pathname)))); }; // resolve-route-target var resolveRouteTarget = function(targetSel) { return (isSxTruthy((isSxTruthy(targetSel) && !isSxTruthy((targetSel == "true")))) ? domQuery(targetSel) : NIL); }; @@ -2453,14 +2486,14 @@ return (function() { var swapStyle = get(swapSpec, "style"); var useTransition = get(swapSpec, "transition"); var trimmed = trim(data); - return (isSxTruthy(!isSxTruthy(isEmpty(trimmed))) ? (isSxTruthy(startsWith(trimmed, "(")) ? (function() { + return (isSxTruthy(!isSxTruthy(isEmpty(trimmed))) ? (disposeIslandsIn(target), (isSxTruthy(startsWith(trimmed, "(")) ? (function() { var rendered = sxRender(trimmed); var container = domCreateElement("div", NIL); domAppend(container, rendered); return withTransition(useTransition, function() { swapDomNodes(target, childrenToFragment(container), swapStyle); return postSwap(target); }); })() : withTransition(useTransition, function() { swapHtmlString(target, trimmed, swapStyle); -return postSwap(target); })) : NIL); +return postSwap(target); }))) : NIL); })(); }; // bind-inline-handlers @@ -2722,6 +2755,12 @@ callExpr.push(dictGet(kwargs, k)); } } return (isSxTruthy(disposers) ? (forEach(function(d) { return (isSxTruthy(isCallable(d)) ? d() : NIL); }, disposers), domSetData(el, "sx-disposers", NIL)) : NIL); })(); }; + // dispose-islands-in + var disposeIslandsIn = function(root) { return (isSxTruthy(root) ? (function() { + var islands = domQueryAll(root, "[data-sx-island]"); + return (isSxTruthy((isSxTruthy(islands) && !isSxTruthy(isEmpty(islands)))) ? (logInfo((String("disposing ") + String(len(islands)) + String(" island(s)"))), forEach(disposeIsland, islands)) : NIL); +})() : NIL); }; + // boot-init var bootInit = function() { return (logInfo((String("sx-browser ") + String(SX_VERSION))), initCssTracking(), processPageScripts(), processSxScripts(NIL), sxHydrateElements(NIL), sxHydrateIslands(NIL), processElements(NIL)); }; @@ -2850,6 +2889,7 @@ return (function() { })(); })(); }; recompute(); + registerInScope(function() { return disposeComputed(s); }); return s; })(); })(); }; @@ -2874,12 +2914,16 @@ return (function() { })(); })()) : NIL); }; runEffect(); - return function() { disposed = true; + return (function() { + var disposeFn = function() { disposed = true; if (isSxTruthy(cleanupFn)) { invoke(cleanupFn); } { var _c = deps; for (var _i = 0; _i < _c.length; _i++) { var dep = _c[_i]; signalRemoveSub(dep, runEffect); } } return (deps = []); }; + registerInScope(disposeFn); + return disposeFn; +})(); })(); })(); }; diff --git a/shared/sx/ref/adapter-dom.sx b/shared/sx/ref/adapter-dom.sx index 86a5b22..84156c8 100644 --- a/shared/sx/ref/adapter-dom.sx +++ b/shared/sx/ref/adapter-dom.sx @@ -182,6 +182,9 @@ (and (starts-with? attr-name "on-") (callable? attr-val)) (dom-listen el (slice attr-name 3) attr-val) + ;; Two-way input binding: :bind signal + (and (= attr-name "bind") (signal? attr-val)) + (bind-input el attr-val) ;; Boolean attr (contains? BOOLEAN_ATTRS attr-name) (when attr-val (dom-set-attr el attr-name "")) @@ -366,19 +369,41 @@ (definition-form? name) (do (trampoline (eval-expr expr env)) (create-fragment)) - ;; map + ;; map — reactive-list when mapping over a signal inside an island (= name "map") - (let ((f (trampoline (eval-expr (nth expr 1) env))) - (coll (trampoline (eval-expr (nth expr 2) env))) - (frag (create-fragment))) - (for-each - (fn (item) - (let ((val (if (lambda? f) - (render-lambda-dom f (list item) env ns) - (render-to-dom (apply f (list item)) env ns)))) - (dom-append frag val))) - coll) - frag) + (let ((coll-expr (nth expr 2))) + (if (and *island-scope* + (= (type-of coll-expr) "list") + (> (len coll-expr) 1) + (= (first coll-expr) "deref")) + ;; Reactive path: pass signal to reactive-list + (let ((f (trampoline (eval-expr (nth expr 1) env))) + (sig (trampoline (eval-expr (nth coll-expr 1) env)))) + (if (signal? sig) + (reactive-list f sig env ns) + ;; deref on non-signal: fall through to static + (let ((coll (deref sig)) + (frag (create-fragment))) + (for-each + (fn (item) + (let ((val (if (lambda? f) + (render-lambda-dom f (list item) env ns) + (render-to-dom (apply f (list item)) env ns)))) + (dom-append frag val))) + coll) + frag))) + ;; Static path: no island scope or no deref + (let ((f (trampoline (eval-expr (nth expr 1) env))) + (coll (trampoline (eval-expr (nth expr 2) env))) + (frag (create-fragment))) + (for-each + (fn (item) + (let ((val (if (lambda? f) + (render-lambda-dom f (list item) env ns) + (render-to-dom (apply f (list item)) env ns)))) + (dom-append frag val))) + coll) + frag))) ;; map-indexed (= name "map-indexed") @@ -567,18 +592,54 @@ (when parent ;; Remove all nodes after marker until next sibling marker (dom-remove-children-after marker) - ;; Render new items - (let ((items (deref items-sig))) + ;; Render new items into a fragment, then insert after marker + (let ((items (deref items-sig)) + (frag (create-fragment))) (for-each (fn (item) (let ((rendered (if (lambda? map-fn) (render-lambda-dom map-fn (list item) env ns) (render-to-dom (apply map-fn (list item)) env ns)))) - (dom-insert-after marker rendered))) - (reverse items))))))) + (dom-append frag rendered))) + items) + (dom-insert-after marker frag)))))) container))) +;; -------------------------------------------------------------------------- +;; bind-input — two-way signal binding for form elements +;; -------------------------------------------------------------------------- +;; +;; (bind-input el sig) creates a bidirectional link: +;; Signal → element: effect updates el.value (or el.checked) when sig changes +;; Element → signal: input/change listener updates sig when user types +;; +;; Handles: input[text/number/email/...], textarea, select, checkbox, radio + +(define bind-input + (fn (el sig) + (let ((input-type (lower (or (dom-get-attr el "type") ""))) + (is-checkbox (or (= input-type "checkbox") + (= input-type "radio")))) + ;; Set initial value from signal + (if is-checkbox + (dom-set-prop el "checked" (deref sig)) + (dom-set-prop el "value" (str (deref sig)))) + ;; Signal → element (reactive effect) + (effect (fn () + (if is-checkbox + (dom-set-prop el "checked" (deref sig)) + (let ((v (str (deref sig)))) + (when (!= (dom-get-prop el "value") v) + (dom-set-prop el "value" v)))))) + ;; Element → signal (event listener) + (dom-listen el (if is-checkbox "change" "input") + (fn (e) + (if is-checkbox + (reset! sig (dom-get-prop el "checked")) + (reset! sig (dom-get-prop el "value")))))))) + + ;; -------------------------------------------------------------------------- ;; Platform interface — DOM adapter ;; -------------------------------------------------------------------------- diff --git a/shared/sx/ref/boot.sx b/shared/sx/ref/boot.sx index a27980a..d3d96d4 100644 --- a/shared/sx/ref/boot.sx +++ b/shared/sx/ref/boot.sx @@ -391,6 +391,15 @@ disposers) (dom-set-data el "sx-disposers" nil))))) +(define dispose-islands-in + (fn (root) + ;; Dispose all islands within root before a swap replaces them. + (when root + (let ((islands (dom-query-all root "[data-sx-island]"))) + (when (and islands (not (empty? islands))) + (log-info (str "disposing " (len islands) " island(s)")) + (for-each dispose-island islands)))))) + ;; -------------------------------------------------------------------------- ;; Full boot sequence diff --git a/shared/sx/ref/orchestration.sx b/shared/sx/ref/orchestration.sx index 778d9f0..fa601e7 100644 --- a/shared/sx/ref/orchestration.sx +++ b/shared/sx/ref/orchestration.sx @@ -261,6 +261,7 @@ ;; Process OOB swaps (process-oob-swaps container (fn (t oob s) + (dispose-islands-in t) (swap-dom-nodes t oob s) (sx-hydrate t) (process-elements t))) @@ -269,6 +270,8 @@ (content (if select-sel (select-from-container container select-sel) (children-to-fragment container)))) + ;; Dispose old islands before swap + (dispose-islands-in target) ;; Swap (with-transition use-transition (fn () @@ -282,6 +285,8 @@ (let ((doc (dom-parse-html-document text))) (when doc (let ((select-sel (dom-get-attr el "sx-select"))) + ;; Dispose old islands before swap + (dispose-islands-in target) (if select-sel ;; Select from parsed document (let ((html (select-html-from-doc doc select-sel))) @@ -295,6 +300,7 @@ ;; Process OOB swaps (process-oob-swaps container (fn (t oob s) + (dispose-islands-in t) (swap-dom-nodes t oob s) (post-swap t))) ;; Hoist head elements @@ -816,6 +822,7 @@ ;; Swap rendered DOM content into target and run post-processing. ;; Shared by pure and data page client routes. (do + (dispose-islands-in target) (dom-set-text-content target "") (dom-append target rendered) (hoist-head-elements-full target) @@ -1003,6 +1010,7 @@ (use-transition (get swap-spec "transition")) (trimmed (trim data))) (when (not (empty? trimmed)) + (dispose-islands-in target) (if (starts-with? trimmed "(") ;; SX response (let ((rendered (sx-render trimmed)) diff --git a/shared/sx/ref/signals.sx b/shared/sx/ref/signals.sx index cee16e2..9595f9e 100644 --- a/shared/sx/ref/signals.sx +++ b/shared/sx/ref/signals.sx @@ -133,6 +133,8 @@ ;; Initial computation (recompute) + ;; Auto-register disposal with island scope + (register-in-scope (fn () (dispose-computed s))) s)))) @@ -176,13 +178,17 @@ (run-effect) ;; Return dispose function - (fn () - (set! disposed true) - (when cleanup-fn (invoke cleanup-fn)) - (for-each - (fn (dep) (signal-remove-sub! dep run-effect)) - deps) - (set! deps (list))))))) + (let ((dispose-fn + (fn () + (set! disposed true) + (when cleanup-fn (invoke cleanup-fn)) + (for-each + (fn (dep) (signal-remove-sub! dep run-effect)) + deps) + (set! deps (list))))) + ;; Auto-register with island scope so disposal happens on swap + (register-in-scope dispose-fn) + dispose-fn))))) ;; -------------------------------------------------------------------------- diff --git a/shared/sx/ref/sx_ref.py b/shared/sx/ref/sx_ref.py index 2c43bb8..3a0f7ce 100644 --- a/shared/sx/ref/sx_ref.py +++ b/shared/sx/ref/sx_ref.py @@ -1339,7 +1339,7 @@ reset_b = lambda s, value: ((lambda old: (_sx_begin(signal_set_value(s, value), swap_b = lambda s, f, *args: ((lambda old: (lambda new_val: (_sx_begin(signal_set_value(s, new_val), notify_subscribers(s)) if sx_truthy((not sx_truthy(is_identical(old, new_val)))) else NIL))(apply(f, cons(old, args))))(signal_value(s)) if sx_truthy(is_signal(s)) else NIL) # computed -computed = lambda compute_fn: (lambda s: (lambda deps: (lambda compute_ctx: (lambda recompute: _sx_begin(recompute(), s))(_sx_fn(lambda : ( +computed = lambda compute_fn: (lambda s: (lambda deps: (lambda compute_ctx: (lambda recompute: _sx_begin(recompute(), register_in_scope(lambda : dispose_computed(s)), s))(_sx_fn(lambda : ( for_each(lambda dep: signal_remove_sub(dep, recompute), signal_deps(s)), signal_set_deps(s, []), (lambda ctx: (lambda prev: _sx_begin(set_tracking_context(ctx), (lambda new_val: _sx_begin(set_tracking_context(prev), signal_set_deps(s, tracking_context_deps(ctx)), (lambda old: _sx_begin(signal_set_value(s, new_val), (notify_subscribers(s) if sx_truthy((not sx_truthy(is_identical(old, new_val)))) else NIL)))(signal_value(s))))(invoke(compute_fn))))(get_tracking_context()))(make_tracking_context(recompute)) @@ -1353,12 +1353,14 @@ def effect(effect_fn): _cells['cleanup_fn'] = NIL run_effect = lambda : (_sx_begin((invoke(_cells['cleanup_fn']) if sx_truthy(_cells['cleanup_fn']) else NIL), for_each(lambda dep: signal_remove_sub(dep, run_effect), _cells['deps']), _sx_cell_set(_cells, 'deps', []), (lambda ctx: (lambda prev: _sx_begin(set_tracking_context(ctx), (lambda result: _sx_begin(set_tracking_context(prev), _sx_cell_set(_cells, 'deps', tracking_context_deps(ctx)), (_sx_cell_set(_cells, 'cleanup_fn', result) if sx_truthy(is_callable(result)) else NIL)))(invoke(effect_fn))))(get_tracking_context()))(make_tracking_context(run_effect))) if sx_truthy((not sx_truthy(_cells['disposed']))) else NIL) run_effect() - return _sx_fn(lambda : ( + dispose_fn = _sx_fn(lambda : ( _sx_cell_set(_cells, 'disposed', True), (invoke(_cells['cleanup_fn']) if sx_truthy(_cells['cleanup_fn']) else NIL), for_each(lambda dep: signal_remove_sub(dep, run_effect), _cells['deps']), _sx_cell_set(_cells, 'deps', []) )[-1]) + register_in_scope(dispose_fn) + return dispose_fn # *batch-depth* _batch_depth = 0 diff --git a/sx/sx/nav-data.sx b/sx/sx/nav-data.sx index 2869c31..b8b72f5 100644 --- a/sx/sx/nav-data.sx +++ b/sx/sx/nav-data.sx @@ -180,7 +180,9 @@ (dict :label "Named Stores" :href "/reactive-islands/named-stores" :summary "Page-level signal containers via def-store/use-store — persist across island destruction/recreation.") (dict :label "Plan" :href "/reactive-islands/plan" - :summary "The full design document — rendering boundary, state flow, signal primitives, island lifecycle."))) + :summary "The full design document — rendering boundary, state flow, signal primitives, island lifecycle.") + (dict :label "Phase 2" :href "/reactive-islands/phase2" + :summary "Input binding, keyed lists, reactive class/style, refs, portals, error boundaries, suspense, transitions."))) (define bootstrappers-nav-items (list (dict :label "Overview" :href "/bootstrappers/") diff --git a/sx/sx/reactive-islands.sx b/sx/sx/reactive-islands.sx index 6743efc..38f3547 100644 --- a/sx/sx/reactive-islands.sx +++ b/sx/sx/reactive-islands.sx @@ -121,11 +121,19 @@ (td :class "px-3 py-2 text-stone-700" "data-sx-emit processing") (td :class "px-3 py-2 text-green-700 font-medium" "Spec'd") (td :class "px-3 py-2 font-mono text-xs text-stone-500" "orchestration.sx: process-emit-elements")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 text-stone-700" "Island disposal") + (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" "boot.sx, orchestration.sx: dispose-islands-in pre-swap")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 text-stone-700" "Reactive list") + (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: map + deref auto-upgrades")) (tr - (td :class "px-3 py-2 text-stone-700" "Keyed list reconciliation") - (td :class "px-3 py-2 text-amber-600 font-medium" "TODO") - (td :class "px-3 py-2 font-mono text-xs text-stone-500" "reactive-list morph")))))))) - + (td :class "px-3 py-2 text-stone-700" "Phase 2") + (td :class "px-3 py-2 text-amber-600 font-medium" "Planned") + (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" "Input binding, keyed lists, refs, portals, ..."))))))))) ;; --------------------------------------------------------------------------- ;; Live demo islands @@ -216,11 +224,61 @@ btn-text) (button :class "px-3 py-1 rounded bg-stone-300 text-stone-700 text-sm hover:bg-stone-400" :on-click (fn (e) - (reset! running false) - (reset! elapsed 0)) + (do (reset! running false) + (reset! elapsed 0))) "Reset"))))) +;; 5. Reactive list — map over a signal, auto-updates when signal changes +(defisland ~demo-reactive-list () + (let ((next-id (signal 1)) + (items (signal (list))) + (add-item (fn (e) + (batch (fn () + (swap! items (fn (old) + (append old (dict "id" (deref next-id) + "text" (str "Item " (deref next-id)))))) + (swap! next-id inc))))) + (remove-item (fn (id) + (swap! items (fn (old) + (filter (fn (item) (not (= (get item "id") id))) old)))))) + (div :class "rounded border border-violet-200 bg-violet-50 p-4 my-4" + (div :class "flex items-center gap-3 mb-3" + (button :class "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700" + :on-click add-item + "Add Item") + (span :class "text-sm text-stone-500" + (deref (computed (fn () (len (deref items))))) " items")) + (ul :class "space-y-1" + (map (fn (item) + (li :class "flex items-center justify-between bg-white rounded px-3 py-2 text-sm" + (span (get item "text")) + (button :class "text-stone-400 hover:text-red-500 text-xs" + :on-click (fn (e) (remove-item (get item "id"))) + "✕"))) + (deref items)))))) + +;; 6. Input binding — two-way signal binding for form elements +(defisland ~demo-input-binding () + (let ((name (signal "")) + (agreed (signal false))) + (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" :bind name + :placeholder "Type your name..." + :class "px-3 py-1.5 rounded border border-stone-300 text-sm focus:outline-none focus:border-violet-400 w-48") + (span :class "text-sm text-stone-600" + "Hello, " + (strong (deref name)) + "!")) + (div :class "flex items-center gap-2" + (input :type "checkbox" :bind agreed :id "agree-cb" + :class "rounded border-stone-300") + (label :for "agree-cb" :class "text-sm text-stone-600" "I agree to the terms")) + (when (deref agreed) + (p :class "text-sm text-green-700" "Thanks for agreeing!"))))) + + ;; --------------------------------------------------------------------------- ;; Demo page — shows what's been implemented ;; --------------------------------------------------------------------------- @@ -256,12 +314,24 @@ (~doc-code :code (highlight "(defisland ~demo-imperative ()\n (let ((count (signal 0))\n (text-node (create-text-node \"0\")))\n ;; Explicit effect: re-runs when count changes\n (effect (fn ()\n (dom-set-text-content text-node (str (deref count)))))\n (div :class \"...\"\n (span text-node)\n (button :on-click (fn (e) (swap! count inc)) \"+\"))))" "lisp")) (p "Two patterns exist: " (strong "declarative") " (" (code "(span (deref sig))") " — auto-reactive via " (code "reactive-text") ") and " (strong "imperative") " (" (code "create-text-node") " + " (code "effect") " — explicit, full control). Use declarative for simple text, imperative for dynamic classes, conditional DOM, or complex updates.")) - (~doc-section :title "5. How defisland Works" :id "demo-island" + (~doc-section :title "5. Reactive List" :id "demo-reactive-list" + (p "When " (code "map") " is used with " (code "(deref signal)") " inside an island, it auto-upgrades to a reactive list. Adding or removing items updates only the affected DOM — no full re-render.") + (~demo-reactive-list) + (~doc-code :code (highlight "(defisland ~demo-reactive-list ()\n (let ((next-id (signal 1))\n (items (signal (list)))\n (add-item (fn (e)\n (batch (fn ()\n (swap! items (fn (old)\n (append old (dict \"id\" (deref next-id)\n \"text\" (str \"Item \" (deref next-id))))))\n (swap! next-id inc)))))\n (remove-item (fn (id)\n (swap! items (fn (old)\n (filter (fn (item) (not (= (get item \"id\") id))) old))))))\n (div\n (button :on-click add-item \"Add Item\")\n (span (deref (computed (fn () (len (deref items))))) \" items\")\n (ul\n (map (fn (item)\n (li (span (get item \"text\"))\n (button :on-click (fn (e) (remove-item (get item \"id\"))) \"✕\")))\n (deref items))))))" "lisp")) + (p "The " (code "map") " form detects " (code "(deref items)") " is a signal and creates an effect that re-renders the list when items change. " (code "batch") " groups the two signal writes (items + next-id) into one update pass.")) + + (~doc-section :title "6. Input Binding" :id "demo-input-binding" + (p "The " (code ":bind") " attribute creates a two-way link between a signal and a form element. Type in the input — the signal updates. Change the signal — the input updates. Works with text inputs, checkboxes, radios, textareas, and selects.") + (~demo-input-binding) + (~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. 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 "6. Test suite" :id "demo-tests" + (~doc-section :title "8. 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"))) @@ -502,10 +572,19 @@ (td :class "px-3 py-2 text-stone-700" "Bootstrapping") (td :class "px-3 py-2 text-green-700 font-medium" "Done") (td :class "px-3 py-2 text-stone-700" "All functions transpiled to JS and Python, platform primitives implemented")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 text-stone-700" "Island disposal") + (td :class "px-3 py-2 text-green-700 font-medium" "Done") + (td :class "px-3 py-2 text-stone-700" "boot.sx, orchestration.sx: effects/computeds auto-register disposers, pre-swap cleanup")) + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 text-stone-700" "Reactive list") + (td :class "px-3 py-2 text-green-700 font-medium" "Done") + (td :class "px-3 py-2 text-stone-700" "adapter-dom.sx: map + deref auto-upgrades to reactive-list")) (tr - (td :class "px-3 py-2 text-stone-700" "Keyed list reconciliation") - (td :class "px-3 py-2 text-amber-600 font-medium" "TODO") - (td :class "px-3 py-2 text-stone-700" "reactive-list clears + re-renders; needs keyed morph for efficient updates")))))) + (td :class "px-3 py-2 text-stone-700" "Phase 2") + (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" "Planned →")) + (td :class "px-3 py-2 text-stone-700" "Input binding, keyed reconciliation, refs, portals, error boundaries, suspense, transitions")))))) (~doc-section :title "Design Principles" :id "principles" (ol :class "space-y-3 text-stone-600 list-decimal list-inside" @@ -517,3 +596,231 @@ (li (strong "No build step.") " Reactive bindings are created at runtime during DOM rendering. No JSX compilation, no Babel transforms, no Vite plugins.")) (p :class "mt-4" "The recommendation from the " (a :href "/essays/client-reactivity" :class "text-violet-700 underline" "Client Reactivity") " essay was: \"Tier 4 probably never.\" This plan is what happens when the answer changes. The design avoids every footgun that essay warns about — no useState cascading to useEffect cascading to Context cascading to a state management library. Signals are one primitive. Islands are one boundary. The rest is composition.")))) + + +;; --------------------------------------------------------------------------- +;; Phase 2 Plan — remaining reactive features +;; --------------------------------------------------------------------------- + +(defcomp ~reactive-islands-phase2-content () + (~doc-page :title "Phase 2: Completing the Reactive Toolkit" + + (~doc-section :title "Where we are" :id "where" + (p "Phase 1 delivered the core reactive primitive: signals, effects, computed values, islands, disposal, stores, event bridges, and reactive DOM rendering. These are sufficient for any isolated interactive widget.") + (p "Phase 2 fills the gaps that appear when you try to build " (em "real application UI") " with islands — forms, modals, dynamic styling, efficient lists, error handling, and async loading. Each feature is independently valuable and independently shippable. None requires changes to the signal runtime.") + + (div :class "overflow-x-auto rounded border border-stone-200 mt-4" + (table :class "w-full text-left text-sm" + (thead (tr :class "border-b border-stone-200 bg-stone-100" + (th :class "px-3 py-2 font-medium text-stone-600" "Feature") + (th :class "px-3 py-2 font-medium text-stone-600" "React equiv.") + (th :class "px-3 py-2 font-medium text-stone-600" "Priority") + (th :class "px-3 py-2 font-medium text-stone-600" "Spec file"))) + (tbody + (tr :class "border-b border-stone-100" + (td :class "px-3 py-2 text-stone-700" "Input binding") + (td :class "px-3 py-2 text-stone-500 text-xs" "controlled inputs") + (td :class "px-3 py-2 text-red-700 font-medium" "P0") + (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" "Keyed reconciliation") + (td :class "px-3 py-2 text-stone-500 text-xs" "key prop") + (td :class "px-3 py-2 text-red-700 font-medium" "P0") + (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-amber-600 font-medium" "P1") + (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-amber-600 font-medium" "P1") + (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") + (td :class "px-3 py-2 text-amber-600 font-medium" "P1") + (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" "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 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 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 font-mono text-xs text-stone-500" "signals.sx")))))) + + ;; ----------------------------------------------------------------------- + ;; P0 — must have + ;; ----------------------------------------------------------------------- + + (~doc-section :title "P0: Input Binding" :id "input-binding" + (p "You cannot build a form without two-way input binding. React uses controlled components — value is always driven by state, onChange feeds back. SX needs the same pattern but with signals instead of setState.") + + (~doc-subsection :title "Design" + (p "A new " (code ":bind") " attribute on " (code "input") ", " (code "textarea") ", and " (code "select") " elements. It takes a signal and creates a bidirectional link: signal value flows into the element, user input flows back into the signal.") + + (~doc-code :code (highlight ";; Bind a signal to an input\n(defisland ~login-form ()\n (let ((email (signal \"\"))\n (password (signal \"\")))\n (form :on-submit (fn (e)\n (dom-prevent-default e)\n (fetch-json \"POST\" \"/api/login\"\n (dict \"email\" (deref email)\n \"password\" (deref password))))\n (input :type \"email\" :bind email\n :placeholder \"Email\")\n (input :type \"password\" :bind password\n :placeholder \"Password\")\n (button :type \"submit\" \"Log in\"))))" "lisp")) + + (p "The " (code ":bind") " attribute is handled in " (code "adapter-dom.sx") "'s element rendering. For a signal " (code "s") ":") + (ol :class "space-y-1 text-stone-600 list-decimal list-inside text-sm" + (li "Set the element's " (code "value") " to " (code "(deref s)") " initially") + (li "Create an effect: when " (code "s") " changes externally, update " (code "el.value")) + (li "Add an " (code "input") " event listener: on user input, call " (code "(reset! s el.value)")) + (li "For checkboxes/radios: bind to " (code "checked") " instead of " (code "value")) + (li "For select: bind to " (code "value") ", handle " (code "change") " event"))) + + (~doc-subsection :title "Spec changes" + (~doc-code :code (highlight ";; In adapter-dom.sx, inside render-dom-element:\n;; After processing :on-* event attrs, check for :bind\n(when (dict-has? kwargs \"bind\")\n (let ((sig (dict-get kwargs \"bind\")))\n (when (signal? sig)\n (bind-input el sig))))\n\n;; New function in adapter-dom.sx:\n(define bind-input\n (fn (el sig)\n (let ((tag (lower (dom-tag-name el)))\n (is-checkbox (or (= (dom-get-attr el \"type\") \"checkbox\")\n (= (dom-get-attr el \"type\") \"radio\"))))\n ;; Set initial value\n (if is-checkbox\n (dom-set-prop el \"checked\" (deref sig))\n (dom-set-prop el \"value\" (str (deref sig))))\n ;; Signal → element (effect, auto-tracked)\n (effect (fn ()\n (if is-checkbox\n (dom-set-prop el \"checked\" (deref sig))\n (let ((v (str (deref sig))))\n (when (!= (dom-get-prop el \"value\") v)\n (dom-set-prop el \"value\" v))))))\n ;; Element → signal (event listener)\n (dom-listen el (if is-checkbox \"change\" \"input\")\n (fn (e)\n (if is-checkbox\n (reset! sig (dom-get-prop el \"checked\"))\n (reset! sig (dom-get-prop el \"value\"))))))))" "lisp")) + + (p "Platform additions: " (code "dom-set-prop") " and " (code "dom-get-prop") " (property access, not attribute — " (code ".value") " not " (code "getAttribute") "). These go in the boundary as IO primitives.")) + + (~doc-subsection :title "Derived patterns" + (p "Input binding composes with everything already built:") + (ul :class "space-y-1 text-stone-600 list-disc pl-5 text-sm" + (li (strong "Validation: ") (code "(computed (fn () (>= (len (deref email)) 3)))") " — derived from the bound signal") + (li (strong "Debounced search: ") "Effect with " (code "set-timeout") " cleanup, reading the bound signal") + (li (strong "Form submission: ") (code "(deref email)") " in the submit handler gives the current value") + (li (strong "Stores: ") "Bind to a store signal — multiple islands share the same form state")))) + + (~doc-section :title "P0: Keyed List Reconciliation" :id "keyed-list" + (p (code "reactive-list") " currently clears all DOM nodes and re-renders from scratch on every signal change. This works for small lists but breaks down for large ones — focus is lost, animations restart, scroll position resets.") + + (~doc-subsection :title "Design" + (p "When items have a " (code ":key") " attribute (or a key function), " (code "reactive-list") " should reconcile by key instead of clearing.") + + (~doc-code :code (highlight ";; Keyed list — items matched by :key, reused across updates\n(defisland ~todo-list ()\n (let ((items (signal (list\n (dict \"id\" 1 \"text\" \"Buy milk\")\n (dict \"id\" 2 \"text\" \"Write spec\")\n (dict \"id\" 3 \"text\" \"Ship it\")))))\n (ul\n (map (fn (item)\n (li :key (get item \"id\")\n (span (get item \"text\"))\n (button :on-click (fn (e) ...)\n \"Remove\")))\n (deref items)))))" "lisp")) + + (p "The reconciliation algorithm:") + (ol :class "space-y-1 text-stone-600 list-decimal list-inside text-sm" + (li "Extract key from each rendered child (from " (code ":key") " attr or item identity)") + (li "Build a map of " (code "old-key → DOM node") " from previous render") + (li "Walk new items: if key exists in old map, " (strong "reuse") " the DOM node (move to correct position). If not, render fresh.") + (li "Remove DOM nodes whose keys are absent from the new list") + (li "Result: minimum DOM mutations. Focus, scroll, animations preserved."))) + + (~doc-subsection :title "Spec changes" + (~doc-code :code (highlight ";; In adapter-dom.sx, replace reactive-list's effect body:\n(define reactive-list\n (fn (map-fn items-sig env ns)\n (let ((marker (create-comment \"island-list\"))\n (key-map (dict)) ;; key → DOM node\n (key-order (list))) ;; current key order\n (effect (fn ()\n (let ((parent (dom-parent marker))\n (items (deref items-sig)))\n (when parent\n (let ((new-map (dict))\n (new-keys (list)))\n ;; Render or reuse each item\n (for-each (fn (item)\n (let ((rendered (render-item map-fn item env ns))\n (key (or (dom-get-attr rendered \"key\")\n (dom-get-data rendered \"key\")\n (identity-key item))))\n (dom-remove-attr rendered \"key\")\n (if (dict-has? key-map key)\n ;; Reuse existing\n (dict-set! new-map key (dict-get key-map key))\n ;; New node\n (dict-set! new-map key rendered))\n (append! new-keys key)))\n items)\n ;; Remove stale nodes\n (for-each (fn (k)\n (when (not (dict-has? new-map k))\n (dom-remove (dict-get key-map k))))\n key-order)\n ;; Reorder to match new-keys\n (let ((cursor marker))\n (for-each (fn (k)\n (let ((node (dict-get new-map k)))\n (when (not (= node (dom-next-sibling cursor)))\n (dom-insert-after cursor node))\n (set! cursor node)))\n new-keys))\n ;; Update state\n (set! key-map new-map)\n (set! key-order new-keys))))))\n marker)))" "lisp")) + + (p "Falls back to current clear-and-rerender when no keys are present."))) + + ;; ----------------------------------------------------------------------- + ;; 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.") + + (~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.") + + (~doc-subsection :title "Design" + (~doc-code :code (highlight ";; portal — render children into a target element\n(defisland ~modal-trigger ()\n (let ((open? (signal false)))\n (div\n (button :on-click (fn (e) (swap! open? not))\n \"Open Modal\")\n\n ;; Portal: children rendered into #modal-root,\n ;; not into this island's DOM\n (portal \"#modal-root\"\n (when (deref open?)\n (div :class \"fixed inset-0 bg-black/50 flex items-center justify-center\"\n (div :class \"bg-white rounded-lg p-6 max-w-md\"\n (h2 \"Modal Title\")\n (p \"This is rendered outside the island's DOM subtree.\")\n (button :on-click (fn (e) (reset! open? false))\n \"Close\"))))))))" "lisp")) + + (p "Implementation in " (code "adapter-dom.sx") ":") + (ol :class "space-y-1 text-stone-600 list-decimal list-inside text-sm" + (li (code "portal") " is a new render-dom form (add to " (code "RENDER_DOM_FORMS") " and " (code "dispatch-render-form") ")") + (li "First arg is a CSS selector string for the target container") + (li "Remaining args are children, rendered normally via " (code "render-to-dom")) + (li "Instead of returning the fragment, append it to the resolved target element") + (li "Return a comment marker in the original position (for disposal tracking)") + (li "On island disposal, portal content is removed from the target"))) + + (~doc-subsection :title "Disposal" + (p "Portals must participate in island disposal. When the island is destroyed, portal content must be removed from its remote target. The " (code "with-island-scope") " mechanism handles this — the portal registers a disposer that removes its children from the target element."))) + + ;; ----------------------------------------------------------------------- + ;; P2 — nice to have + ;; ----------------------------------------------------------------------- + + (~doc-section :title "P2: Error Boundaries" :id "error-boundaries" + (p "When an island's rendering or effect throws, the error currently propagates to the top level and may crash other islands. An error boundary catches the error and renders a fallback UI.") + + (~doc-subsection :title "Design" + (~doc-code :code (highlight ";; error-boundary — catch errors in island subtrees\n(defisland ~resilient-widget ()\n (error-boundary\n ;; Fallback: shown when children throw\n (fn (err)\n (div :class \"p-4 bg-red-50 border border-red-200 rounded\"\n (p :class \"text-red-700 font-medium\" \"Something went wrong\")\n (p :class \"text-red-500 text-sm\" (error-message err))))\n ;; Children: the happy path\n (do\n (~risky-component)\n (~another-component))))" "lisp")) + + (p "Implementation:") + (ol :class "space-y-1 text-stone-600 list-decimal list-inside text-sm" + (li (code "error-boundary") " is a new render-dom form") + (li "First arg: fallback function " (code "(fn (error) ...)") " that returns DOM") + (li "Remaining args: children rendered inside a try/catch") + (li "On error: clear the boundary container, render fallback with the caught error") + (li "Effects within the boundary are disposed on error") + (li "A " (code "retry") " function is passed to the fallback for recovery")))) + + (~doc-section :title "P2: Suspense" :id "suspense" + (p "Suspense handles async operations in the render path — data fetching, lazy-loaded components, code splitting. Show a loading placeholder until the async work completes, then swap in the result.") + + (~doc-subsection :title "Design" + (~doc-code :code (highlight ";; suspense — async-aware rendering boundary\n(defisland ~user-profile (&key user-id)\n (suspense\n ;; Fallback: shown during loading\n (div :class \"animate-pulse\"\n (div :class \"h-4 bg-stone-200 rounded w-3/4\")\n (div :class \"h-4 bg-stone-200 rounded w-1/2 mt-2\"))\n ;; Children: may contain async operations\n (let ((user (await (fetch-json (str \"/api/users/\" user-id)))))\n (div\n (h2 (get user \"name\"))\n (p (get user \"email\"))))))" "lisp")) + + (p "This requires a new primitive concept: a " (strong "resource") " — an async signal that transitions through loading → resolved → error states.") + + (~doc-code :code (highlight ";; resource — async signal\n(define resource\n (fn (fetch-fn)\n ;; Returns a signal-like value:\n ;; {:loading true :data nil :error nil} initially\n ;; {:loading false :data result :error nil} on success\n ;; {:loading false :data nil :error err} on failure\n (let ((state (signal (dict \"loading\" true\n \"data\" nil\n \"error\" nil))))\n ;; Kick off the async operation\n (promise-then (fetch-fn)\n (fn (data) (reset! state (dict \"loading\" false\n \"data\" data\n \"error\" nil)))\n (fn (err) (reset! state (dict \"loading\" false\n \"data\" nil\n \"error\" err))))\n state)))" "lisp")) + + (p "Suspense is the rendering boundary; resource is the data primitive. Together they give a clean async data story without effects-that-fetch (React's " (code "useEffect") " + " (code "useState") " anti-pattern)."))) + + (~doc-section :title "P2: Transitions" :id "transitions" + (p "Transitions mark updates as non-urgent. The UI stays interactive during expensive re-renders. React's " (code "startTransition") " defers state updates so that urgent updates (typing, clicking) aren't blocked by slow ones (filtering a large list, rendering a complex subtree).") + + (~doc-subsection :title "Design" + (~doc-code :code (highlight ";; transition — non-urgent signal update\n(defisland ~search-results (&key items)\n (let ((query (signal \"\"))\n (filtered (signal items))\n (is-pending (signal false)))\n ;; Typing is urgent — updates immediately\n ;; Filtering is deferred — doesn't block input\n (effect (fn ()\n (let ((q (deref query)))\n (transition is-pending\n (fn ()\n (reset! filtered\n (filter (fn (item)\n (contains? (lower (get item \"name\")) (lower q)))\n items)))))))\n (div\n (input :bind query :placeholder \"Search...\")\n (when (deref is-pending)\n (span :class \"text-stone-400\" \"Filtering...\"))\n (ul (map (fn (item) (li (get item \"name\")))\n (deref filtered))))))" "lisp")) + + (p (code "transition") " takes a pending-signal and a thunk. It sets pending to true, schedules the thunk via " (code "requestIdleCallback") " (or " (code "setTimeout 0") " as fallback), then sets pending to false when complete. Signal writes inside the thunk are batched and applied asynchronously.") + (p "This is lower priority because SX's fine-grained updates already avoid the re-render-everything problem that makes transitions critical in React. But for truly large lists or expensive computations, deferral is still valuable."))) + + ;; ----------------------------------------------------------------------- + ;; Implementation order + ;; ----------------------------------------------------------------------- + + (~doc-section :title "Implementation Order" :id "order" + (p "Each feature is independent. Suggested order based on dependency and value:") + (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.") + (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.")) + + (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.")) + + (~doc-section :title "What we are NOT building" :id "not-building" + (p "Some React features are deliberately excluded:") + (ul :class "space-y-2 text-stone-600 list-disc pl-5" + (li (strong "Virtual DOM / diffing") " — SX uses fine-grained signals. There is no component re-render to diff against. The " (code "reactive-text") ", " (code "reactive-attr") ", " (code "reactive-fragment") ", and " (code "reactive-list") " primitives update the exact DOM nodes that changed.") + (li (strong "JSX / template compilation") " — SX is interpreted at runtime. No build step. The s-expression syntax " (em "is") " the component tree — there is nothing to compile.") + (li (strong "Server components (React-style)") " — SX already has a richer version. The " (code "aser") " mode evaluates server-side logic and serializes the result as SX wire format. Components can be expanded on the server or deferred to the client. This is more flexible than React's server/client component split.") + (li (strong "Concurrent rendering / fiber") " — React's fiber architecture exists to time-slice component re-renders. SX has no component re-renders to slice. Fine-grained updates are inherently incremental.") + (li (strong "Hooks rules") " — Signals are values, not hooks. No rules about ordering, no conditional creation restrictions, no dependency arrays. This is a feature, not a gap."))))) diff --git a/sx/sxc/pages/docs.sx b/sx/sxc/pages/docs.sx index d850619..143761a 100644 --- a/sx/sxc/pages/docs.sx +++ b/sx/sxc/pages/docs.sx @@ -668,6 +668,7 @@ "event-bridge" (~reactive-islands-event-bridge-content) "named-stores" (~reactive-islands-named-stores-content) "plan" (~reactive-islands-plan-content) + "phase2" (~reactive-islands-phase2-content) :else (~reactive-islands-index-content))) ;; ---------------------------------------------------------------------------