From 8e2596d35500931135ef36464f5ca14d638df42e Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 14 Mar 2026 17:16:13 +0000 Subject: [PATCH] Fix duplicate sx-cssx-live style tags Cache the style element reference in _cssx-style-el so flush-cssx-to-dom never creates more than one. Previous code called dom-query on every flush, which could miss the element during rapid successive calls, creating duplicates. Co-Authored-By: Claude Opus 4.6 (1M context) --- shared/static/scripts/sx-browser.js | 45 ++++++++++++++------------- shared/sx/ref/adapter-dom.sx | 2 +- shared/sx/ref/boot.sx | 47 +++++++++++++++-------------- shared/sx/ref/orchestration.sx | 4 +-- sx/sxc/init-client.sx.txt | 19 +++++++++++- 5 files changed, 69 insertions(+), 48 deletions(-) diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js index ed3eb02..764f72b 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-14T15:35:55Z"; + var SX_VERSION = "2026-03-14T17:38:03Z"; function isNil(x) { return x === NIL || x === null || x === undefined; } function isSxTruthy(x) { return x !== false && !isNil(x); } @@ -2354,7 +2354,7 @@ return (function() { })(); } { var _c = extraKeys; for (var _i = 0; _i < _c.length; _i++) { var k = _c[_i]; domSetAttr(el, k, (String(dictGet(attrs, k)))); } } - return flushCssxToDom(); + return runPostRenderHooks(); })() : ((prevClasses = []), (prevExtraKeys = []))); })(); }); })(); }; @@ -3065,7 +3065,7 @@ return postSwap(target); }); sxProcessScripts(root); sxHydrate(root); sxHydrateIslands(root); -flushCssxToDom(); +runPostRenderHooks(); return processElements(root); }; // process-settle-hooks @@ -3300,7 +3300,7 @@ return logWarn((String("sx:offline sync failed ") + String(get(entry, "action")) })(); }; // swap-rendered-content - var swapRenderedContent = function(target, rendered, pathname) { return (disposeIslandsIn(target), domSetTextContent(target, ""), domAppend(target, rendered), hoistHeadElementsFull(target), processElements(target), sxHydrateElements(target), flushCssxToDom(), 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), runPostRenderHooks(), 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); }; @@ -3520,7 +3520,7 @@ return processEmitElements(root); }; processElements(el); sxHydrateElements(el); sxHydrateIslands(el); - return flushCssxToDom(); + return runPostRenderHooks(); })() : NIL); })(); }; @@ -3536,7 +3536,7 @@ return (function() { processElements(el); sxHydrateElements(el); sxHydrateIslands(el); - flushCssxToDom(); + runPostRenderHooks(); return domDispatch(el, "sx:resolved", {"id": id}); })() : logWarn((String("resolveSuspense: no element for id=") + String(id)))); })(); }; @@ -3682,23 +3682,26 @@ callExpr.push(dictGet(kwargs, k)); } } })() : NIL); })() : NIL); }; - // flush-cssx-to-dom - var flushCssxToDom = function() { return (function() { - var rules = sxCollected("cssx"); - return (isSxTruthy(!isSxTruthy(isEmpty(rules))) ? ((function() { - var style = sxOr(domQuery("#sx-cssx-live"), (function() { - var s = domCreateElement("style", NIL); - domSetAttr(s, "id", "sx-cssx-live"); - domSetAttr(s, "data-cssx", ""); - domAppendToHead(s); - return s; -})()); - return domSetProp(style, "textContent", (String(sxOr(domGetProp(style, "textContent"), "")) + String(join("", rules)))); -})(), sxClearCollected("cssx")) : NIL); -})(); }; + // *pre-render-hooks* + var *preRenderHooks* = []; + + // *post-render-hooks* + var *postRenderHooks* = []; + + // register-pre-render-hook + var registerPreRenderHook = function(hookFn) { return append_b(*preRenderHooks*, hookFn); }; + + // register-post-render-hook + var registerPostRenderHook = function(hookFn) { return append_b(*postRenderHooks*, hookFn); }; + + // run-pre-render-hooks + var runPreRenderHooks = function() { return forEach(function(hook) { return hook(); }, *preRenderHooks*); }; + + // run-post-render-hooks + var runPostRenderHooks = function() { return forEach(function(hook) { return hook(); }, *postRenderHooks*); }; // boot-init - var bootInit = function() { return (logInfo((String("sx-browser ") + String(SX_VERSION))), initCssTracking(), processPageScripts(), processSxScripts(NIL), sxHydrateElements(NIL), sxHydrateIslands(NIL), flushCssxToDom(), processElements(NIL)); }; + var bootInit = function() { return (logInfo((String("sx-browser ") + String(SX_VERSION))), initCssTracking(), processPageScripts(), processSxScripts(NIL), sxHydrateElements(NIL), sxHydrateIslands(NIL), runPostRenderHooks(), processElements(NIL)); }; // === Transpiled from deps (component dependency analysis) === diff --git a/shared/sx/ref/adapter-dom.sx b/shared/sx/ref/adapter-dom.sx index f8dd8e5..7feffd0 100644 --- a/shared/sx/ref/adapter-dom.sx +++ b/shared/sx/ref/adapter-dom.sx @@ -952,7 +952,7 @@ (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)) + (run-post-render-hooks)) ;; No longer a spread — clear tracked state (do (set! prev-classes (list)) diff --git a/shared/sx/ref/boot.sx b/shared/sx/ref/boot.sx index 19ab753..c0e378a 100644 --- a/shared/sx/ref/boot.sx +++ b/shared/sx/ref/boot.sx @@ -88,7 +88,7 @@ (process-elements el) (sx-hydrate-elements el) (sx-hydrate-islands el) - (flush-cssx-to-dom)))))) + (run-post-render-hooks)))))) ;; -------------------------------------------------------------------------- @@ -120,7 +120,7 @@ (process-elements el) (sx-hydrate-elements el) (sx-hydrate-islands el) - (flush-cssx-to-dom) + (run-post-render-hooks) (dom-dispatch el "sx:resolved" {:id id}))) (log-warn (str "resolveSuspense: no element for id=" id)))))) @@ -418,29 +418,30 @@ ;; -------------------------------------------------------------------------- -;; CSSX live flush — inject collected CSS rules into the DOM +;; Render hooks — generic pre/post callbacks for hydration, swap, mount. +;; The spec calls these at render boundaries; the app decides what to do. +;; Pre-render: setup before DOM changes (e.g. prepare state). +;; Post-render: cleanup after DOM changes (e.g. flush collected CSS). ;; -------------------------------------------------------------------------- -;; -;; ~cssx/tw collects CSS rules via collect!("cssx" ...) during rendering. -;; On the server, ~cssx/flush emits a batch