From 2c97542ee8c514856ab34ee149ec085a786f9da0 Mon Sep 17 00:00:00 2001 From: giles Date: Fri, 13 Mar 2026 04:37:45 +0000 Subject: [PATCH] Fix island dep scanning + spread-through-reactive-if debug deps.sx: scan island bodies for component deps (was only scanning "component" and "macro", missing "island" type). This ensures ~cssx/tw and its dependencies are sent to the client for islands. cssx.sx: move if inside make-spread arg so it's evaluated by eval-expr (no reactive wrapping) instead of render-to-dom which applies reactive-if inside island scope, converting the spread into a fragment and losing the class attrs. Added island dep tests at 3 levels: test-deps.sx (spec), test_deps.py (Python), test_parity.py (ref vs fallback). sx-browser.js: temporary debug logging at spread detection points. Co-Authored-By: Claude Opus 4.6 --- shared/static/scripts/sx-browser.js | 14 ++++++++++---- shared/sx/ref/deps.sx | 4 ++-- shared/sx/ref/sx_ref.py | 4 ++-- shared/sx/ref/test-deps.sx | 24 +++++++++++++++++++++++- shared/sx/templates/cssx.sx | 10 ++++++---- shared/sx/tests/test_deps.py | 11 +++++++++++ shared/sx/tests/test_parity.py | 15 +++++++++++++++ 7 files changed, 69 insertions(+), 13 deletions(-) diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js index faf940b..4f7b3dc 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:08:59Z"; + var SX_VERSION = "2026-03-13T04:16:14Z"; function isNil(x) { return x === NIL || x === null || x === undefined; } function isSxTruthy(x) { return x !== false && !isNil(x); } @@ -1934,6 +1934,8 @@ 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); + if (child && child._spread) console.log("[sx-debug] SPREAD detected in element child:", tag, child.attrs); + if (child && !child._spread && child.nodeType === 11 && arg && arg[0] && arg[0].name && arg[0].name.indexOf("cssx") >= 0) console.log("[sx-debug] ~cssx child NOT spread:", tag, "type:", typeof child, "nodeType:", child.nodeType, "childNodes:", child.childNodes ? child.childNodes.length : "N/A"); return (isSxTruthy(isSpread(child)) ? forEach(function(key) { return (function() { var val = dictGet(spreadAttrs(child), key); return (isSxTruthy((key == "class")) ? (function() { @@ -1974,7 +1976,9 @@ return (function() { var _m = typeOf(expr); if (_m == "nil") return createFragme return envSet(local, "children", childFrag); })(); } - return renderToDom(componentBody(comp), local, ns); + var _compResult = renderToDom(componentBody(comp), local, ns); + if (componentName(comp).indexOf("cssx") >= 0) console.log("[sx-debug] renderDomComponent", componentName(comp), "returned:", _compResult, "isSpread:", isSpread(_compResult), "type:", typeOf(_compResult), "_spread:", _compResult && _compResult._spread); + return _compResult; })(); })(); }; @@ -2017,8 +2021,10 @@ return (function() { var _m = typeOf(expr); if (_m == "nil") return createFragme var condVal = trampoline(evalExpr(nth(expr, 1), env)); return (isSxTruthy(condVal) ? renderToDom(nth(expr, 2), env, ns) : (isSxTruthy((len(expr) > 3)) ? renderToDom(nth(expr, 3), env, ns) : createFragment())); })(); + if (result && result._spread) console.log("[sx-debug] reactive-if result IS a spread:", result.attrs, "— will be LOST in fragment wrapping"); return (isSxTruthy(domParent(marker)) ? (forEach(function(n) { return domRemove(n); }, currentNodes), (currentNodes = (isSxTruthy(domIsFragment(result)) ? domChildNodes(result) : [result])), domInsertAfter(marker, result)) : (initialResult = result)); })(); }); + if (initialResult && initialResult._spread) console.log("[sx-debug] reactive-if initialResult IS a spread — returning frag instead of spread!"); return (function() { var frag = createFragment(); domAppend(frag, marker); @@ -3601,7 +3607,7 @@ callExpr.push(dictGet(kwargs, k)); } } // transitive-deps-walk var transitiveDepsWalk = function(n, seen, env) { return (isSxTruthy(!isSxTruthy(contains(seen, n))) ? (append_b(seen, n), (function() { var val = envGet(env, n); - return (isSxTruthy((typeOf(val) == "component")) ? forEach(function(ref) { return transitiveDepsWalk(ref, seen, env); }, scanRefs(componentBody(val))) : (isSxTruthy((typeOf(val) == "macro")) ? forEach(function(ref) { return transitiveDepsWalk(ref, seen, env); }, scanRefs(macroBody(val))) : NIL)); + return (isSxTruthy(sxOr((typeOf(val) == "component"), (typeOf(val) == "island"))) ? forEach(function(ref) { return transitiveDepsWalk(ref, seen, env); }, scanRefs(componentBody(val))) : (isSxTruthy((typeOf(val) == "macro")) ? forEach(function(ref) { return transitiveDepsWalk(ref, seen, env); }, scanRefs(macroBody(val))) : NIL)); })()) : NIL); }; // transitive-deps @@ -3615,7 +3621,7 @@ callExpr.push(dictGet(kwargs, k)); } } // compute-all-deps var computeAllDeps = function(env) { return forEach(function(name) { return (function() { var val = envGet(env, name); - return (isSxTruthy((typeOf(val) == "component")) ? componentSetDeps(val, transitiveDeps(name, env)) : NIL); + return (isSxTruthy(sxOr((typeOf(val) == "component"), (typeOf(val) == "island"))) ? componentSetDeps(val, transitiveDeps(name, env)) : NIL); })(); }, envComponents(env)); }; // scan-components-from-source diff --git a/shared/sx/ref/deps.sx b/shared/sx/ref/deps.sx index b28becb..92bf559 100644 --- a/shared/sx/ref/deps.sx +++ b/shared/sx/ref/deps.sx @@ -73,7 +73,7 @@ (append! seen n) (let ((val (env-get env n))) (cond - (= (type-of val) "component") + (or (= (type-of val) "component") (= (type-of val) "island")) (for-each (fn ((ref :as string)) (transitive-deps-walk ref seen env)) (scan-refs (component-body val))) (= (type-of val) "macro") @@ -105,7 +105,7 @@ (for-each (fn ((name :as string)) (let ((val (env-get env name))) - (when (= (type-of val) "component") + (when (or (= (type-of val) "component") (= (type-of val) "island")) (component-set-deps! val (transitive-deps name env))))) (env-components env)))) diff --git a/shared/sx/ref/sx_ref.py b/shared/sx/ref/sx_ref.py index c1bc37b..1c75517 100644 --- a/shared/sx/ref/sx_ref.py +++ b/shared/sx/ref/sx_ref.py @@ -2757,7 +2757,7 @@ def transitive_deps_walk(n, seen, env): if sx_truthy((not sx_truthy(contains_p(seen, n)))): seen.append(n) val = env_get(env, n) - if sx_truthy((type_of(val) == 'component')): + if sx_truthy(((type_of(val) == 'component') if sx_truthy((type_of(val) == 'component')) else (type_of(val) == 'island'))): for ref in scan_refs(component_body(val)): transitive_deps_walk(ref, seen, env) return NIL @@ -2780,7 +2780,7 @@ def transitive_deps(name, env): def compute_all_deps(env): for name in env_components(env): val = env_get(env, name) - if sx_truthy((type_of(val) == 'component')): + if sx_truthy(((type_of(val) == 'component') if sx_truthy((type_of(val) == 'component')) else (type_of(val) == 'island'))): component_set_deps(val, transitive_deps(name, env)) return NIL diff --git a/shared/sx/ref/test-deps.sx b/shared/sx/ref/test-deps.sx index b35e3b3..44f0730 100644 --- a/shared/sx/ref/test-deps.sx +++ b/shared/sx/ref/test-deps.sx @@ -36,6 +36,13 @@ (defcomp ~dep-island () (div "no deps")) +;; Islands with dependencies — defisland bodies must be scanned +(defisland ~dep-island-with-child () + (div (~dep-leaf) "island content")) + +(defisland ~dep-island-with-chain () + (div (~dep-branch) "deep island")) + ;; -------------------------------------------------------------------------- ;; 1. scan-refs — finds component references in AST nodes @@ -145,6 +152,15 @@ (deftest "accepts name without tilde" (let ((deps (transitive-deps "dep-branch" (test-env)))) + (assert-contains "~dep-leaf" deps))) + + (deftest "island direct dep scanned" + (let ((deps (transitive-deps "~dep-island-with-child" (test-env)))) + (assert-contains "~dep-leaf" deps))) + + (deftest "island transitive deps scanned" + (let ((deps (transitive-deps "~dep-island-with-chain" (test-env)))) + (assert-contains "~dep-branch" deps) (assert-contains "~dep-leaf" deps)))) @@ -173,7 +189,13 @@ (deftest "handles multiple top-level components" (let ((needed (components-needed "(div (~dep-leaf) (~dep-island))" (test-env)))) (assert-contains "~dep-leaf" needed) - (assert-contains "~dep-island" needed)))) + (assert-contains "~dep-island" needed))) + + (deftest "island deps included in page bundle" + (let ((needed (components-needed "(~dep-island-with-chain)" (test-env)))) + (assert-contains "~dep-island-with-chain" needed) + (assert-contains "~dep-branch" needed) + (assert-contains "~dep-leaf" needed)))) ;; -------------------------------------------------------------------------- diff --git a/shared/sx/templates/cssx.sx b/shared/sx/templates/cssx.sx index 309d9e2..f90264d 100644 --- a/shared/sx/templates/cssx.sx +++ b/shared/sx/templates/cssx.sx @@ -476,10 +476,12 @@ (classes (map (fn (r) (get r "cls")) valid)) (rules (map (fn (r) (get r "rule")) valid)) (_ (for-each (fn (rule) (collect! "cssx" rule)) rules))) - ;; Return spread: injects class + data-tw onto parent element - (if (empty? classes) - nil - (make-spread {"class" (join " " classes) + ;; Return spread: injects class + data-tw onto parent element. + ;; The if is inside make-spread's arg so it goes through eval-expr + ;; (not render-to-dom), avoiding reactive-if wrapping in islands. + (make-spread (if (empty? classes) + {} + {"class" (join " " classes) "data-tw" (or tokens "")})))) diff --git a/shared/sx/tests/test_deps.py b/shared/sx/tests/test_deps.py index 3ba88a4..7c35c9e 100644 --- a/shared/sx/tests/test_deps.py +++ b/shared/sx/tests/test_deps.py @@ -113,6 +113,17 @@ class TestTransitiveDeps: deps = transitive_deps("~card", env) assert "~unknown" in deps + def test_island_deps_scanned(self): + """Island bodies must be scanned for component dependencies.""" + env = make_env( + '(defcomp ~leaf (&key) (span "leaf"))', + '(defcomp ~branch (&key) (div (~leaf)))', + '(defisland ~my-island () (div (~branch) "island"))', + ) + deps = transitive_deps("~my-island", env) + assert "~branch" in deps + assert "~leaf" in deps + def test_without_tilde_prefix(self): env = make_env( '(defcomp ~card (&key) (div (~shared:misc/badge)))', diff --git a/shared/sx/tests/test_parity.py b/shared/sx/tests/test_parity.py index 4e3bf32..fb3cc49 100644 --- a/shared/sx/tests/test_parity.py +++ b/shared/sx/tests/test_parity.py @@ -705,6 +705,21 @@ class TestParityDeps: ref_d = ref_env[key].deps assert set(hw_d) == set(ref_d), f"Deps mismatch for {key}" + def test_transitive_deps_island(self): + """Island bodies must be scanned for component deps.""" + from shared.sx.deps import _transitive_deps_fallback + from shared.sx.ref.sx_ref import transitive_deps as ref_td + hw_env, ref_env = self._make_envs( + '(defcomp ~leaf (&key) (span "leaf"))', + '(defcomp ~branch (&key) (div (~leaf)))', + '(defisland ~my-island () (div (~branch) "island content"))', + ) + hw_deps = _transitive_deps_fallback("~my-island", hw_env) + ref_deps = set(ref_td("~my-island", ref_env)) + assert hw_deps == ref_deps + assert "~branch" in ref_deps + assert "~leaf" in ref_deps + def test_scan_components_from_sx(self): from shared.sx.deps import _scan_components_from_sx_fallback from shared.sx.ref.sx_ref import scan_components_from_source as ref_sc