diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js index 8a3e988..5827d98 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-13T04:50:39Z"; + var SX_VERSION = "2026-03-13T05:11:12Z"; function isNil(x) { return x === NIL || x === null || x === undefined; } function isSxTruthy(x) { return x !== false && !isNil(x); } @@ -1934,7 +1934,7 @@ return (function() { var _m = typeOf(expr); if (_m == "nil") return createFragme return assoc(state, "skip", true, "i", (get(state, "i") + 1)); })() : ((isSxTruthy(!isSxTruthy(contains(VOID_ELEMENTS, tag))) ? (function() { var child = renderToDom(arg, env, newNs); - return (isSxTruthy(isSpread(child)) ? forEach(function(key) { return (function() { + return (isSxTruthy((isSxTruthy(isSpread(child)) && _islandScope)) ? reactiveSpread(el, function() { return renderToDom(arg, env, newNs); }) : (isSxTruthy(isSpread(child)) ? forEach(function(key) { return (function() { var val = dictGet(spreadAttrs(child), key); return (isSxTruthy((key == "class")) ? (function() { var existing = domGetAttr(el, "class"); @@ -1943,7 +1943,7 @@ return (function() { var _m = typeOf(expr); if (_m == "nil") return createFragme var existing = domGetAttr(el, "style"); return domSetAttr(el, "style", (isSxTruthy((isSxTruthy(existing) && !isSxTruthy((existing == "")))) ? (String(existing) + String(";") + String(val)) : val)); })() : domSetAttr(el, key, (String(val))))); -})(); }, keys(spreadAttrs(child))) : domAppend(el, child)); +})(); }, keys(spreadAttrs(child))) : domAppend(el, child))); })() : NIL), assoc(state, "i", (get(state, "i") + 1))))); })(); }, {["i"]: 0, ["skip"]: false}, args); return el; @@ -2269,6 +2269,44 @@ return effect(function() { return (function() { })(); })(); }); }; + // reactive-spread + var reactiveSpread = function(el, renderFn) { return (function() { + var prevClasses = []; + var prevExtraKeys = []; + (function() { + var existing = sxOr(domGetAttr(el, "data-sx-reactive-attrs"), ""); + return domSetAttr(el, "data-sx-reactive-attrs", (isSxTruthy(isEmpty(existing)) ? "_spread" : (String(existing) + String(",_spread")))); +})(); + return effect(function() { if (isSxTruthy(!isSxTruthy(isEmpty(prevClasses)))) { + (function() { + var current = sxOr(domGetAttr(el, "class"), ""); + var tokens = filter(function(c) { return !isSxTruthy((c == "")); }, split(current, " ")); + var kept = filter(function(c) { return !isSxTruthy(some(function(pc) { return (pc == c); }, prevClasses)); }, tokens); + return (isSxTruthy(isEmpty(kept)) ? domRemoveAttr(el, "class") : domSetAttr(el, "class", join(" ", kept))); +})(); +} +{ var _c = prevExtraKeys; for (var _i = 0; _i < _c.length; _i++) { var k = _c[_i]; domRemoveAttr(el, k); } } +return (function() { + var result = renderFn(); + return (isSxTruthy(isSpread(result)) ? (function() { + var attrs = spreadAttrs(result); + var clsStr = sxOr(dictGet(attrs, "class"), ""); + var newClasses = filter(function(c) { return !isSxTruthy((c == "")); }, split(clsStr, " ")); + var extraKeys = filter(function(k) { return !isSxTruthy((k == "class")); }, keys(attrs)); + prevClasses = newClasses; + prevExtraKeys = extraKeys; + if (isSxTruthy(!isSxTruthy(isEmpty(newClasses)))) { + (function() { + var current = sxOr(domGetAttr(el, "class"), ""); + return domSetAttr(el, "class", (isSxTruthy((isSxTruthy(current) && !isSxTruthy((current == "")))) ? (String(current) + String(" ") + String(clsStr)) : clsStr)); +})(); +} + { var _c = extraKeys; for (var _i = 0; _i < _c.length; _i++) { var k = _c[_i]; domSetAttr(el, k, (String(dictGet(attrs, k)))); } } + return flushCssxToDom(); +})() : ((prevClasses = []), (prevExtraKeys = []))); +})(); }); +})(); }; + // reactive-fragment var reactiveFragment = function(testFn, renderFn, env, ns) { return (function() { var marker = createComment("island-fragment"); diff --git a/shared/sx/ref/adapter-dom.sx b/shared/sx/ref/adapter-dom.sx index 2b3c315..40edf4f 100644 --- a/shared/sx/ref/adapter-dom.sx +++ b/shared/sx/ref/adapter-dom.sx @@ -232,30 +232,35 @@ (do (when (not (contains? VOID_ELEMENTS tag)) (let ((child (render-to-dom arg env new-ns))) - (if (spread? child) - ;; Spread: merge attrs onto parent element - (for-each - (fn ((key :as string)) - (let ((val (dict-get (spread-attrs child) key))) - (if (= key "class") - ;; Class: append to existing - (let ((existing (dom-get-attr el "class"))) - (dom-set-attr el "class" - (if (and existing (not (= existing ""))) - (str existing " " val) - val))) - (if (= key "style") - ;; Style: append with semicolon - (let ((existing (dom-get-attr el "style"))) - (dom-set-attr el "style" + (cond + ;; Reactive spread: track signal deps, update attrs on change + (and (spread? child) *island-scope*) + (reactive-spread el (fn () (render-to-dom arg env new-ns))) + ;; Static spread: one-shot merge attrs onto parent element + (spread? child) + (for-each + (fn ((key :as string)) + (let ((val (dict-get (spread-attrs child) key))) + (if (= key "class") + ;; Class: append to existing + (let ((existing (dom-get-attr el "class"))) + (dom-set-attr el "class" (if (and existing (not (= existing ""))) - (str existing ";" val) + (str existing " " val) val))) - ;; Other attrs: overwrite - (dom-set-attr el key (str val)))))) - (keys (spread-attrs child))) + (if (= key "style") + ;; Style: append with semicolon + (let ((existing (dom-get-attr el "style"))) + (dom-set-attr el "style" + (if (and existing (not (= existing ""))) + (str existing ";" val) + val))) + ;; Other attrs: overwrite + (dom-set-attr el key (str val)))))) + (keys (spread-attrs child))) ;; Normal child: append to element - (dom-append el child)))) + :else + (dom-append el child)))) (assoc state "i" (inc (get state "i")))))))) (dict "i" 0 "skip" false) args) @@ -867,6 +872,64 @@ :else (dom-set-attr el attr-name (str val))))))))) +;; reactive-spread — reactively bind spread attrs to parent element. +;; Used when a child of an element produces a spread inside an island. +;; Tracks signal deps in the spread expression. When signals change: +;; old classes are removed, new ones applied. Non-class attrs (data-tw etc.) +;; are overwritten. Flushes newly collected CSS rules to live stylesheet. +;; +;; Multiple reactive spreads on the same element are safe — each tracks +;; its own class contribution and only removes/adds its own tokens. +(define reactive-spread :effects [render mutation] + (fn (el (render-fn :as lambda)) + (let ((prev-classes (list)) + (prev-extra-keys (list))) + ;; Mark for morph protection + (let ((existing (or (dom-get-attr el "data-sx-reactive-attrs") ""))) + (dom-set-attr el "data-sx-reactive-attrs" + (if (empty? existing) "_spread" (str existing ",_spread")))) + (effect (fn () + ;; 1. Remove previously applied classes from element's class list + (when (not (empty? prev-classes)) + (let ((current (or (dom-get-attr el "class") "")) + (tokens (filter (fn (c) (not (= c ""))) (split current " "))) + (kept (filter (fn (c) + (not (some (fn (pc) (= pc c)) prev-classes))) + tokens))) + (if (empty? kept) + (dom-remove-attr el "class") + (dom-set-attr el "class" (join " " kept))))) + ;; 2. Remove previously applied extra attrs + (for-each (fn (k) (dom-remove-attr el k)) prev-extra-keys) + ;; 3. Re-evaluate the spread expression (tracks signal deps) + (let ((result (render-fn))) + (if (spread? result) + (let ((attrs (spread-attrs result)) + (cls-str (or (dict-get attrs "class") "")) + (new-classes (filter (fn (c) (not (= c ""))) + (split cls-str " "))) + (extra-keys (filter (fn (k) (not (= k "class"))) + (keys attrs)))) + (set! prev-classes new-classes) + (set! prev-extra-keys extra-keys) + ;; Append new classes to element + (when (not (empty? new-classes)) + (let ((current (or (dom-get-attr el "class") ""))) + (dom-set-attr el "class" + (if (and current (not (= current ""))) + (str current " " cls-str) + cls-str)))) + ;; Set extra attrs (data-tw, etc.) — simple overwrite + (for-each (fn (k) + (dom-set-attr el k (str (dict-get attrs k)))) + extra-keys) + ;; Flush any newly collected CSS rules to live stylesheet + (flush-cssx-to-dom)) + ;; No longer a spread — clear tracked state + (do + (set! prev-classes (list)) + (set! prev-extra-keys (list)))))))))) + ;; reactive-fragment — conditionally render a fragment based on a signal ;; Used for (when (deref sig) ...) or (if (deref sig) ...) inside an island. (define reactive-fragment :effects [render mutation] diff --git a/sx/sx/nav-data.sx b/sx/sx/nav-data.sx index 24ade0c..efd7081 100644 --- a/sx/sx/nav-data.sx +++ b/sx/sx/nav-data.sx @@ -370,6 +370,8 @@ :children (list {:label "Reference" :href "/sx/(geography.(hypermedia.(reference)))" :children reference-nav-items} {:label "Examples" :href "/sx/(geography.(hypermedia.(example)))" :children examples-nav-items})} + {:label "Spreads" :href "/sx/(geography.(spreads))" + :summary "Child-to-parent communication across render boundaries — spread, collect!, reactive-spread, and the path to provide/context/emit!."} {:label "Marshes" :href "/sx/(geography.(marshes))" :summary "Where reactivity and hypermedia interpenetrate — server writes to signals, reactive transforms reshape server content, client state modifies how hypermedia is interpreted."} {:label "Isomorphism" :href "/sx/(geography.(isomorphism))" :children isomorphism-nav-items})} diff --git a/sx/sx/spreads.sx b/sx/sx/spreads.sx new file mode 100644 index 0000000..0558f3b --- /dev/null +++ b/sx/sx/spreads.sx @@ -0,0 +1,235 @@ +;; --------------------------------------------------------------------------- +;; Spreads — child-to-parent communication across render boundaries +;; --------------------------------------------------------------------------- + +(defcomp ~geography/spreads-content () + (~docs/page :title "Spreads" + + (p :class "text-stone-500 text-sm italic mb-8" + "A spread is a value that, when returned as a child of an element, " + "injects attributes onto its parent instead of rendering as content. " + "This inverts the normal direction of data flow: children tell parents how to look.") + + ;; ===================================================================== + ;; I. The primitives + ;; ===================================================================== + + (~docs/section :title "Three primitives" :id "primitives" + (p "The spread system has three orthogonal primitives. Each operates at a " + "different level of the render pipeline.") + + (~docs/subsection :title "1. make-spread / spread? / spread-attrs" + (p "A spread is a value type. " (code "make-spread") " creates one from a dict of " + "attributes. When the renderer encounters a spread as a child of an element, " + "it merges the attrs onto the parent element instead of appending a DOM node.") + (~docs/code :code "(defcomp ~highlight (&key colour) + (make-spread {\"class\" (str \"highlight-\" colour) + \"data-highlight\" colour}))") + (p "Use it as a child of any element:") + (~docs/code :code "(div (~highlight :colour \"yellow\") + \"This div gets class=highlight-yellow\")") + (p (code "class") " values are appended (space-joined). " + (code "style") " values are appended (semicolon-joined). " + "All other attributes overwrite.")) + + (~docs/subsection :title "2. collect! / collected / clear-collected!" + (p "Render-time accumulators. Values are collected into named buckets " + "during rendering and retrieved at flush points. Deduplication is automatic.") + (~docs/code :code ";; Deep inside a component tree: +(collect! \"cssx\" \".sx-bg-red-500{background-color:hsl(0,72%,53%)}\") + +;; At the flush point (once, in the layout): +(let ((rules (collected \"cssx\"))) + (clear-collected! \"cssx\") + (raw! (str \"\")))") + (p "This is upward communication through the render tree: " + "a deeply nested component contributes a CSS rule, and the layout " + "emits all accumulated rules as a single " (code "