From 36b070f7960844ac5d4a7a135e66c3b9709e1a7c Mon Sep 17 00:00:00 2001 From: giles Date: Fri, 13 Mar 2026 05:16:13 +0000 Subject: [PATCH 1/2] =?UTF-8?q?Add=20reactive=20spreads=20=E2=80=94=20sign?= =?UTF-8?q?al-driven=20attribute=20injection=20in=20islands?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a spread value (e.g. from ~cssx/tw) appears inside an island with signal-dependent tokens, reactive-spread tracks deps and updates the element's class/attrs when signals change. Old classes are surgically removed, new ones appended, and freshly collected CSS rules are flushed to the live stylesheet. Multiple reactive spreads on one element are safe. Co-Authored-By: Claude Opus 4.6 --- shared/static/scripts/sx-browser.js | 44 +++++++++++- shared/sx/ref/adapter-dom.sx | 105 ++++++++++++++++++++++------ 2 files changed, 125 insertions(+), 24 deletions(-) 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] From 9806aec60cdefd7de53f69c853a818126327cb07 Mon Sep 17 00:00:00 2001 From: giles Date: Fri, 13 Mar 2026 05:25:42 +0000 Subject: [PATCH 2/2] =?UTF-8?q?Add=20Spreads=20page=20under=20Geography=20?= =?UTF-8?q?=E2=80=94=20spread/collect/reactive-spread=20docs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents the three orthogonal primitives (spread, collect!, reactive-spread), their operation across server/client/morph boundaries, CSSX as use case, semantic style variables, and the planned provide/context/emit! unification. Co-Authored-By: Claude Opus 4.6 --- sx/sx/nav-data.sx | 2 + sx/sx/spreads.sx | 235 ++++++++++++++++++++++++++++++++++++++ sx/sxc/pages/docs.sx | 10 ++ sx/sxc/pages/sx_router.py | 2 + 4 files changed, 249 insertions(+) create mode 100644 sx/sx/spreads.sx diff --git a/sx/sx/nav-data.sx b/sx/sx/nav-data.sx index 8f4d7a5..20c9fd3 100644 --- a/sx/sx/nav-data.sx +++ b/sx/sx/nav-data.sx @@ -368,6 +368,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 "