diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js index 34416bf..dd29465 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-13T19:15:06Z"; + var SX_VERSION = "2026-03-13T22:49:51Z"; function isNil(x) { return x === NIL || x === null || x === undefined; } function isSxTruthy(x) { return x !== false && !isNil(x); } @@ -61,13 +61,6 @@ } SxSignal.prototype._signal = true; - function TrackingCtx(notifyFn) { - this.notifyFn = notifyFn; - this.deps = []; - } - - var _trackingContext = null; - function Macro(params, restParam, body, closure, name) { this.params = params; this.restParam = restParam; @@ -243,12 +236,6 @@ function signalRemoveSub(s, fn) { var i = s.subscribers.indexOf(fn); if (i >= 0) s.subscribers.splice(i, 1); } function signalDeps(s) { return s.deps.slice(); } function signalSetDeps(s, deps) { s.deps = Array.isArray(deps) ? deps.slice() : []; } - function setTrackingContext(ctx) { _trackingContext = ctx; } - function getTrackingContext() { return _trackingContext || NIL; } - function makeTrackingContext(notifyFn) { return new TrackingCtx(notifyFn); } - function trackingContextDeps(ctx) { return ctx ? ctx.deps : []; } - function trackingContextAddDep(ctx, s) { if (ctx && ctx.deps.indexOf(s) < 0) ctx.deps.push(s); } - function trackingContextNotifyFn(ctx) { return ctx ? ctx.notifyFn : NIL; } // invoke — call any callable (native fn or SX lambda) with args. // Transpiled code emits direct calls f(args) which fail on SX lambdas @@ -1208,14 +1195,7 @@ return append_b(inits, nth(binding, 1)); }, bindings) : reduce(function(acc, pai var before = trampoline(evalExpr(first(args), env)); var body = trampoline(evalExpr(nth(args, 1), env)); var after = trampoline(evalExpr(nth(args, 2), env)); - callThunk(before, env); - pushWind(before, after); - return (function() { - var result = callThunk(body, env); - popWind(); - callThunk(after, env); - return result; -})(); + return dynamicWindCall(before, body, after, env); })(); }; // sf-scope @@ -1936,7 +1916,7 @@ return result; }, args); // render-to-dom var renderToDom = function(expr, env, ns) { setRenderActiveB(true); -return (function() { var _m = typeOf(expr); if (_m == "nil") return createFragment(); if (_m == "boolean") return createFragment(); if (_m == "raw-html") return domParseHtml(rawHtmlContent(expr)); if (_m == "string") return createTextNode(expr); if (_m == "number") return createTextNode((String(expr))); if (_m == "symbol") return renderToDom(trampoline(evalExpr(expr, env)), env, ns); if (_m == "keyword") return createTextNode(keywordName(expr)); if (_m == "dom-node") return expr; if (_m == "spread") return (sxEmit("element-attrs", spreadAttrs(expr)), expr); if (_m == "dict") return createFragment(); if (_m == "list") return (isSxTruthy(isEmpty(expr)) ? createFragment() : renderDomList(expr, env, ns)); return (isSxTruthy(isSignal(expr)) ? (isSxTruthy(_islandScope) ? reactiveText(expr) : createTextNode((String(deref(expr))))) : createTextNode((String(expr)))); })(); }; +return (function() { var _m = typeOf(expr); if (_m == "nil") return createFragment(); if (_m == "boolean") return createFragment(); if (_m == "raw-html") return domParseHtml(rawHtmlContent(expr)); if (_m == "string") return createTextNode(expr); if (_m == "number") return createTextNode((String(expr))); if (_m == "symbol") return renderToDom(trampoline(evalExpr(expr, env)), env, ns); if (_m == "keyword") return createTextNode(keywordName(expr)); if (_m == "dom-node") return expr; if (_m == "spread") return (sxEmit("element-attrs", spreadAttrs(expr)), expr); if (_m == "dict") return createFragment(); if (_m == "list") return (isSxTruthy(isEmpty(expr)) ? createFragment() : renderDomList(expr, env, ns)); return (isSxTruthy(isSignal(expr)) ? (isSxTruthy(sxContext("sx-island-scope", NIL)) ? reactiveText(expr) : createTextNode((String(deref(expr))))) : createTextNode((String(expr)))); })(); }; // render-dom-list var renderDomList = function(expr, env, ns) { return (function() { @@ -1947,7 +1927,7 @@ return (function() { var _m = typeOf(expr); if (_m == "nil") return createFragme return (isSxTruthy((name == "raw!")) ? renderDomRaw(args, env) : (isSxTruthy((name == "<>")) ? renderDomFragment(args, env, ns) : (isSxTruthy((name == "lake")) ? renderDomLake(args, env, ns) : (isSxTruthy((name == "marsh")) ? renderDomMarsh(args, env, ns) : (isSxTruthy(startsWith(name, "html:")) ? renderDomElement(slice(name, 5), args, env, ns) : (isSxTruthy(isRenderDomForm(name)) ? (isSxTruthy((isSxTruthy(contains(HTML_TAGS, name)) && sxOr((isSxTruthy((len(args) > 0)) && (typeOf(first(args)) == "keyword")), ns))) ? renderDomElement(name, args, env, ns) : dispatchRenderForm(name, expr, env, ns)) : (isSxTruthy((isSxTruthy(envHas(env, name)) && isMacro(envGet(env, name)))) ? renderToDom(expandMacro(envGet(env, name), args, env), env, ns) : (isSxTruthy(contains(HTML_TAGS, name)) ? renderDomElement(name, args, env, ns) : (isSxTruthy((isSxTruthy(startsWith(name, "~")) && isSxTruthy(envHas(env, name)) && isIsland(envGet(env, name)))) ? renderDomIsland(envGet(env, name), args, env, ns) : (isSxTruthy(startsWith(name, "~")) ? (function() { var comp = envGet(env, name); return (isSxTruthy(isComponent(comp)) ? renderDomComponent(comp, args, env, ns) : renderDomUnknownComponent(name)); -})() : (isSxTruthy((isSxTruthy((indexOf_(name, "-") > 0)) && isSxTruthy((len(args) > 0)) && (typeOf(first(args)) == "keyword"))) ? renderDomElement(name, args, env, ns) : (isSxTruthy(ns) ? renderDomElement(name, args, env, ns) : (isSxTruthy((isSxTruthy((name == "deref")) && _islandScope)) ? (function() { +})() : (isSxTruthy((isSxTruthy((indexOf_(name, "-") > 0)) && isSxTruthy((len(args) > 0)) && (typeOf(first(args)) == "keyword"))) ? renderDomElement(name, args, env, ns) : (isSxTruthy(ns) ? renderDomElement(name, args, env, ns) : (isSxTruthy((isSxTruthy((name == "deref")) && sxContext("sx-island-scope", NIL))) ? (function() { var sigOrVal = trampoline(evalExpr(first(args), env)); return (isSxTruthy(isSignal(sigOrVal)) ? reactiveText(sigOrVal) : createTextNode((String(deref(sigOrVal))))); })() : renderToDom(trampoline(evalExpr(expr, env)), env, ns)))))))))))))); @@ -1983,14 +1963,14 @@ return (function() { var _m = typeOf(expr); if (_m == "nil") return createFragme })() : (isSxTruthy((attrName == "key")) ? (function() { var attrVal = trampoline(evalExpr(attrExpr, env)); return domSetAttr(el, "key", (String(attrVal))); -})() : (isSxTruthy(_islandScope) ? reactiveAttr(el, attrName, function() { return trampoline(evalExpr(attrExpr, env)); }) : (function() { +})() : (isSxTruthy(sxContext("sx-island-scope", NIL)) ? reactiveAttr(el, attrName, function() { return trampoline(evalExpr(attrExpr, env)); }) : (function() { var attrVal = trampoline(evalExpr(attrExpr, env)); return (isSxTruthy(sxOr(isNil(attrVal), (attrVal == false))) ? NIL : (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))) ? (function() { var child = renderToDom(arg, env, newNs); - return (isSxTruthy((isSxTruthy(isSpread(child)) && _islandScope)) ? reactiveSpread(el, function() { return renderToDom(arg, env, newNs); }) : (isSxTruthy(isSpread(child)) ? NIL : domAppend(el, child))); + return (isSxTruthy((isSxTruthy(isSpread(child)) && sxContext("sx-island-scope", NIL))) ? reactiveSpread(el, function() { return renderToDom(arg, env, newNs); }) : (isSxTruthy(isSpread(child)) ? NIL : domAppend(el, child))); })() : NIL), assoc(state, "i", (get(state, "i") + 1))))); })(); }, {["i"]: 0, ["skip"]: false}, args); { var _c = sxEmitted("element-attrs"); for (var _i = 0; _i < _c.length; _i++) { var spreadDict = _c[_i]; { var _c = keys(spreadDict); for (var _i = 0; _i < _c.length; _i++) { var key = _c[_i]; (function() { @@ -2066,7 +2046,7 @@ return (function() { var _m = typeOf(expr); if (_m == "nil") return createFragme var isRenderDomForm = function(name) { return contains(RENDER_DOM_FORMS, name); }; // dispatch-render-form - var dispatchRenderForm = function(name, expr, env, ns) { return (isSxTruthy((name == "if")) ? (isSxTruthy(_islandScope) ? (function() { + var dispatchRenderForm = function(name, expr, env, ns) { return (isSxTruthy((name == "if")) ? (isSxTruthy(sxContext("sx-island-scope", NIL)) ? (function() { var marker = createComment("r-if"); var currentNodes = []; var initialResult = NIL; @@ -2089,7 +2069,7 @@ return (function() { var _m = typeOf(expr); if (_m == "nil") return createFragme })() : (function() { 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())); -})()) : (isSxTruthy((name == "when")) ? (isSxTruthy(_islandScope) ? (function() { +})()) : (isSxTruthy((name == "when")) ? (isSxTruthy(sxContext("sx-island-scope", NIL)) ? (function() { var marker = createComment("r-when"); var currentNodes = []; var initialResult = NIL; @@ -2116,7 +2096,7 @@ return (function() { var _m = typeOf(expr); if (_m == "nil") return createFragme var frag = createFragment(); { var _c = range(2, 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((name == "cond")) ? (isSxTruthy(_islandScope) ? (function() { +})())) : (isSxTruthy((name == "cond")) ? (isSxTruthy(sxContext("sx-island-scope", NIL)) ? (function() { var marker = createComment("r-cond"); var currentNodes = []; var initialResult = NIL; @@ -2162,7 +2142,7 @@ return (function() { var _m = typeOf(expr); if (_m == "nil") return createFragme 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)) && isSxTruthy((typeOf(first(collExpr)) == "symbol")) && (symbolName(first(collExpr)) == "deref"))) ? (function() { + return (isSxTruthy((isSxTruthy(sxContext("sx-island-scope", NIL)) && isSxTruthy((typeOf(collExpr) == "list")) && isSxTruthy((len(collExpr) > 1)) && isSxTruthy((typeOf(first(collExpr)) == "symbol")) && (symbolName(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() { @@ -2490,15 +2470,13 @@ return (isSxTruthy(testFn()) ? (function() { domSetAttr(container, "data-sx-boundary", "true"); effect(function() { deref(retryVersion); domSetProp(container, "innerHTML", ""); -return (function() { - var savedScope = _islandScope; - _islandScope = NIL; - return tryCatch(function() { (function() { +scopePush("sx-island-scope", NIL); +return tryCatch(function() { (function() { var frag = createFragment(); { var _c = bodyExprs; for (var _i = 0; _i < _c.length; _i++) { var child = _c[_i]; domAppend(frag, renderToDom(child, env, ns)); } } return domAppend(container, frag); })(); -return (_islandScope = savedScope); }, function(err) { _islandScope = savedScope; +return scopePop("sx-island-scope"); }, function(err) { scopePop("sx-island-scope"); return (function() { var fallbackFn = trampoline(evalExpr(fallbackExpr, env)); var retryFn = function() { return swap_b(retryVersion, function(n) { return (n + 1); }); }; @@ -2506,8 +2484,7 @@ return (function() { var fallbackDom = (isSxTruthy(isLambda(fallbackFn)) ? renderLambdaDom(fallbackFn, [err, retryFn], env, ns) : renderToDom(apply(fallbackFn, [err, retryFn]), env, ns)); return domAppend(container, fallbackDom); })(); -})(); }); -})(); }); +})(); }); }); return container; })(); }; @@ -4272,10 +4249,13 @@ callExpr.push(dictGet(kwargs, k)); } } // deref var deref = function(s) { return (isSxTruthy(!isSxTruthy(isSignal(s))) ? s : (function() { - var ctx = getTrackingContext(); + var ctx = sxContext("sx-reactive", NIL); if (isSxTruthy(ctx)) { - trackingContextAddDep(ctx, s); - signalAddSub(s, trackingContextNotifyFn(ctx)); + (function() { + var depList = get(ctx, "deps"); + var notifyFn = get(ctx, "notify"); + return (isSxTruthy(!isSxTruthy(contains(depList, s))) ? (append_b(depList, s), signalAddSub(s, notifyFn)) : NIL); +})(); } return signalValue(s); })()); }; @@ -4302,21 +4282,18 @@ callExpr.push(dictGet(kwargs, k)); } } var recompute = function() { { var _c = signalDeps(s); for (var _i = 0; _i < _c.length; _i++) { var dep = _c[_i]; signalRemoveSub(dep, recompute); } } signalSetDeps(s, []); return (function() { - var ctx = makeTrackingContext(recompute); - return (function() { - var prev = getTrackingContext(); - setTrackingContext(ctx); + var ctx = {["deps"]: [], ["notify"]: recompute}; + scopePush("sx-reactive", ctx); return (function() { var newVal = invoke(computeFn); - setTrackingContext(prev); - signalSetDeps(s, trackingContextDeps(ctx)); + scopePop("sx-reactive"); + signalSetDeps(s, get(ctx, "deps")); return (function() { var old = signalValue(s); signalSetValue(s, newVal); return (isSxTruthy(!isSxTruthy(isIdentical(old, newVal))) ? notifySubscribers(s) : NIL); })(); })(); -})(); })(); }; recompute(); registerInScope(function() { return disposeComputed(s); }); @@ -4331,17 +4308,14 @@ return (function() { var cleanupFn = NIL; return (function() { var runEffect = function() { return (isSxTruthy(!isSxTruthy(disposed)) ? ((isSxTruthy(cleanupFn) ? invoke(cleanupFn) : NIL), forEach(function(dep) { return signalRemoveSub(dep, runEffect); }, deps), (deps = []), (function() { - var ctx = makeTrackingContext(runEffect); - return (function() { - var prev = getTrackingContext(); - setTrackingContext(ctx); + var ctx = {["deps"]: [], ["notify"]: runEffect}; + scopePush("sx-reactive", ctx); return (function() { var result = invoke(effectFn); - setTrackingContext(prev); - deps = trackingContextDeps(ctx); + scopePop("sx-reactive"); + deps = get(ctx, "deps"); return (isSxTruthy(isCallable(result)) ? (cleanupFn = result) : NIL); })(); -})(); })()) : NIL); }; runEffect(); return (function() { @@ -4390,22 +4364,19 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() { // dispose-computed var disposeComputed = function(s) { return (isSxTruthy(isSignal(s)) ? (forEach(function(dep) { return signalRemoveSub(dep, NIL); }, signalDeps(s)), signalSetDeps(s, [])) : NIL); }; - // *island-scope* - var _islandScope = NIL; - // with-island-scope - var withIslandScope = function(scopeFn, bodyFn) { return (function() { - var prev = _islandScope; - _islandScope = scopeFn; - return (function() { + var withIslandScope = function(scopeFn, bodyFn) { scopePush("sx-island-scope", scopeFn); +return (function() { var result = bodyFn(); - _islandScope = prev; + scopePop("sx-island-scope"); return result; -})(); })(); }; // register-in-scope - var registerInScope = function(disposable) { return (isSxTruthy(_islandScope) ? _islandScope(disposable) : NIL); }; + var registerInScope = function(disposable) { return (function() { + var collector = sxContext("sx-island-scope", NIL); + return (isSxTruthy(collector) ? invoke(collector, disposable) : NIL); +})(); }; // with-marsh-scope var withMarshScope = function(marshEl, bodyFn) { return (function() { diff --git a/shared/sx/ref/adapter-dom.sx b/shared/sx/ref/adapter-dom.sx index 323b23e..18a40fb 100644 --- a/shared/sx/ref/adapter-dom.sx +++ b/shared/sx/ref/adapter-dom.sx @@ -59,7 +59,7 @@ ;; Signal → reactive text in island scope, deref outside :else (if (signal? expr) - (if *island-scope* + (if (context "sx-island-scope" nil) (reactive-text expr) (create-text-node (str (deref expr)))) (create-text-node (str expr)))))) @@ -143,7 +143,7 @@ (render-dom-element name args env ns) ;; deref in island scope → reactive text node - (and (= name "deref") *island-scope*) + (and (= name "deref") (context "sx-island-scope" nil)) (let ((sig-or-val (trampoline (eval-expr (first args) env)))) (if (signal? sig-or-val) (reactive-text sig-or-val) @@ -215,7 +215,7 @@ ;; Inside island scope: reactive attribute binding. ;; The effect tracks signal deps automatically — if none ;; are deref'd, it fires once and never again (safe). - *island-scope* + (context "sx-island-scope" nil) (reactive-attr el attr-name (fn () (trampoline (eval-expr attr-expr env)))) ;; Static attribute (outside islands) @@ -237,7 +237,7 @@ (let ((child (render-to-dom arg env new-ns))) (cond ;; Reactive spread: track signal deps, update attrs on change - (and (spread? child) *island-scope*) + (and (spread? child) (context "sx-island-scope" nil)) (reactive-spread el (fn () (render-to-dom arg env new-ns))) ;; Static spread: already emitted via provide, skip (spread? child) nil @@ -392,7 +392,7 @@ (cond ;; if — reactive inside islands (re-renders when signal deps change) (= name "if") - (if *island-scope* + (if (context "sx-island-scope" nil) (let ((marker (create-comment "r-if")) (current-nodes (list)) (initial-result nil)) @@ -440,7 +440,7 @@ ;; when — reactive inside islands (= name "when") - (if *island-scope* + (if (context "sx-island-scope" nil) (let ((marker (create-comment "r-when")) (current-nodes (list)) (initial-result nil)) @@ -486,7 +486,7 @@ ;; cond — reactive inside islands (= name "cond") - (if *island-scope* + (if (context "sx-island-scope" nil) (let ((marker (create-comment "r-cond")) (current-nodes (list)) (initial-result nil)) @@ -563,7 +563,7 @@ ;; map — reactive-list when mapping over a signal inside an island (= name "map") (let ((coll-expr (nth expr 2))) - (if (and *island-scope* + (if (and (context "sx-island-scope" nil) (= (type-of coll-expr) "list") (> (len coll-expr) 1) (= (type-of (first coll-expr)) "symbol") @@ -1168,7 +1168,7 @@ (dom-set-attr container "data-sx-boundary" "true") ;; The entire body is rendered inside ONE effect + try-catch. - ;; Body renders WITHOUT *island-scope* so that if/when/cond use static + ;; Body renders WITHOUT island scope so that if/when/cond use static ;; paths — their signal reads become direct deref calls tracked by THIS ;; effect. Errors from signal changes throw synchronously within try-catch. ;; The error boundary's own effect handles all reactivity for its subtree. @@ -1179,31 +1179,30 @@ ;; Clear container (dom-set-prop container "innerHTML" "") - ;; Save and clear island scope BEFORE try-catch so it can be - ;; restored in both success and error paths. - (let ((saved-scope *island-scope*)) - (set! *island-scope* nil) - (try-catch - (fn () - ;; Body renders statically — signal reads tracked by THIS effect, - ;; throws propagate to our try-catch. - (let ((frag (create-fragment))) - (for-each - (fn (child) - (dom-append frag (render-to-dom child env ns))) - body-exprs) - (dom-append container frag)) - (set! *island-scope* saved-scope)) - (fn (err) - ;; Restore scope first, then render fallback - (set! *island-scope* saved-scope) + ;; Push nil island scope to suppress reactive rendering in body. + ;; Pop in both success and error paths. + (scope-push! "sx-island-scope" nil) + (try-catch + (fn () + ;; Body renders statically — signal reads tracked by THIS effect, + ;; throws propagate to our try-catch. + (let ((frag (create-fragment))) + (for-each + (fn (child) + (dom-append frag (render-to-dom child env ns))) + body-exprs) + (dom-append container frag)) + (scope-pop! "sx-island-scope")) + (fn (err) + ;; Pop scope first, then render fallback + (scope-pop! "sx-island-scope") (let ((fallback-fn (trampoline (eval-expr fallback-expr env))) (retry-fn (fn () (swap! retry-version (fn (n) (+ n 1)))))) (let ((fallback-dom (if (lambda? fallback-fn) (render-lambda-dom fallback-fn (list err retry-fn) env ns) (render-to-dom (apply fallback-fn (list err retry-fn)) env ns)))) - (dom-append container fallback-dom)))))))) + (dom-append container fallback-dom))))))) container))) diff --git a/shared/sx/ref/bootstrap_py.py b/shared/sx/ref/bootstrap_py.py index 57f3725..56fc1ca 100644 --- a/shared/sx/ref/bootstrap_py.py +++ b/shared/sx/ref/bootstrap_py.py @@ -165,12 +165,6 @@ class PyEmitter: "signal-remove-sub!": "signal_remove_sub", "signal-deps": "signal_deps", "signal-set-deps!": "signal_set_deps", - "set-tracking-context!": "set_tracking_context", - "get-tracking-context": "get_tracking_context", - "make-tracking-context": "make_tracking_context", - "tracking-context-deps": "tracking_context_deps", - "tracking-context-add-dep!": "tracking_context_add_dep", - "tracking-context-notify-fn": "tracking_context_notify_fn", "identical?": "is_identical", "notify-subscribers": "notify_subscribers", "flush-subscribers": "flush_subscribers", @@ -179,7 +173,6 @@ class PyEmitter: "register-in-scope": "register_in_scope", "*batch-depth*": "_batch_depth", "*batch-queue*": "_batch_queue", - "*island-scope*": "_island_scope", "*store-registry*": "_store_registry", "def-store": "def_store", "use-store": "use_store", diff --git a/shared/sx/ref/js.sx b/shared/sx/ref/js.sx index 9bd79d4..743817a 100644 --- a/shared/sx/ref/js.sx +++ b/shared/sx/ref/js.sx @@ -87,12 +87,6 @@ "signal-remove-sub!" "signalRemoveSub" "signal-deps" "signalDeps" "signal-set-deps!" "signalSetDeps" - "set-tracking-context!" "setTrackingContext" - "get-tracking-context" "getTrackingContext" - "make-tracking-context" "makeTrackingContext" - "tracking-context-deps" "trackingContextDeps" - "tracking-context-add-dep!" "trackingContextAddDep" - "tracking-context-notify-fn" "trackingContextNotifyFn" "identical?" "isIdentical" "notify-subscribers" "notifySubscribers" "flush-subscribers" "flushSubscribers" @@ -101,7 +95,6 @@ "register-in-scope" "registerInScope" "*batch-depth*" "_batchDepth" "*batch-queue*" "_batchQueue" - "*island-scope*" "_islandScope" "*store-registry*" "_storeRegistry" "def-store" "defStore" "use-store" "useStore" diff --git a/shared/sx/ref/platform_js.py b/shared/sx/ref/platform_js.py index 69ea298..75d5533 100644 --- a/shared/sx/ref/platform_js.py +++ b/shared/sx/ref/platform_js.py @@ -858,13 +858,6 @@ PREAMBLE = '''\ } SxSignal.prototype._signal = true; - function TrackingCtx(notifyFn) { - this.notifyFn = notifyFn; - this.deps = []; - } - - var _trackingContext = null; - function Macro(params, restParam, body, closure, name) { this.params = params; this.restParam = restParam; @@ -1269,12 +1262,6 @@ PLATFORM_JS_PRE = ''' function signalRemoveSub(s, fn) { var i = s.subscribers.indexOf(fn); if (i >= 0) s.subscribers.splice(i, 1); } function signalDeps(s) { return s.deps.slice(); } function signalSetDeps(s, deps) { s.deps = Array.isArray(deps) ? deps.slice() : []; } - function setTrackingContext(ctx) { _trackingContext = ctx; } - function getTrackingContext() { return _trackingContext || NIL; } - function makeTrackingContext(notifyFn) { return new TrackingCtx(notifyFn); } - function trackingContextDeps(ctx) { return ctx ? ctx.deps : []; } - function trackingContextAddDep(ctx, s) { if (ctx && ctx.deps.indexOf(s) < 0) ctx.deps.push(s); } - function trackingContextNotifyFn(ctx) { return ctx ? ctx.notifyFn : NIL; } // invoke — call any callable (native fn or SX lambda) with args. // Transpiled code emits direct calls f(args) which fail on SX lambdas diff --git a/shared/sx/ref/platform_py.py b/shared/sx/ref/platform_py.py index be63bd4..d3ef8e7 100644 --- a/shared/sx/ref/platform_py.py +++ b/shared/sx/ref/platform_py.py @@ -481,17 +481,6 @@ class _Signal: self.deps = [] -class _TrackingContext: - """Context for discovering signal dependencies.""" - __slots__ = ("notify_fn", "deps") - def __init__(self, notify_fn): - self.notify_fn = notify_fn - self.deps = [] - - -_tracking_context = None - - def make_signal(value): return _Signal(value) @@ -532,33 +521,6 @@ def signal_set_deps(s, deps): s.deps = list(deps) if isinstance(deps, list) else [] -def set_tracking_context(ctx): - global _tracking_context - _tracking_context = ctx - - -def get_tracking_context(): - global _tracking_context - return _tracking_context if _tracking_context is not None else NIL - - -def make_tracking_context(notify_fn): - return _TrackingContext(notify_fn) - - -def tracking_context_deps(ctx): - return ctx.deps if isinstance(ctx, _TrackingContext) else [] - - -def tracking_context_add_dep(ctx, s): - if isinstance(ctx, _TrackingContext) and s not in ctx.deps: - ctx.deps.append(s) - - -def tracking_context_notify_fn(ctx): - return ctx.notify_fn if isinstance(ctx, _TrackingContext) else NIL - - def invoke(f, *args): """Call f with args — handles both native callables and SX lambdas. diff --git a/shared/sx/ref/py.sx b/shared/sx/ref/py.sx index 8582428..3a66658 100644 --- a/shared/sx/ref/py.sx +++ b/shared/sx/ref/py.sx @@ -84,12 +84,6 @@ "signal-remove-sub!" "signal_remove_sub" "signal-deps" "signal_deps" "signal-set-deps!" "signal_set_deps" - "set-tracking-context!" "set_tracking_context" - "get-tracking-context" "get_tracking_context" - "make-tracking-context" "make_tracking_context" - "tracking-context-deps" "tracking_context_deps" - "tracking-context-add-dep!" "tracking_context_add_dep" - "tracking-context-notify-fn" "tracking_context_notify_fn" "identical?" "is_identical" "notify-subscribers" "notify_subscribers" "flush-subscribers" "flush_subscribers" @@ -98,7 +92,6 @@ "register-in-scope" "register_in_scope" "*batch-depth*" "_batch_depth" "*batch-queue*" "_batch_queue" - "*island-scope*" "_island_scope" "*store-registry*" "_store_registry" "def-store" "def_store" "use-store" "use_store" diff --git a/shared/sx/ref/run_signal_tests.py b/shared/sx/ref/run_signal_tests.py new file mode 100644 index 0000000..5332096 --- /dev/null +++ b/shared/sx/ref/run_signal_tests.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Run test-signals.sx using the bootstrapped evaluator with signal primitives. + +Uses bootstrapped signal functions from sx_ref.py directly, patching invoke +to handle SX lambdas from the interpreter (test expressions create lambdas +that need evaluator dispatch). +""" +from __future__ import annotations +import os, sys + +_HERE = os.path.dirname(os.path.abspath(__file__)) +_PROJECT = os.path.abspath(os.path.join(_HERE, "..", "..", "..")) +sys.path.insert(0, _PROJECT) + +from shared.sx.parser import parse_all +from shared.sx.ref import sx_ref +from shared.sx.ref.sx_ref import eval_expr, trampoline, make_env, scope_push, scope_pop, sx_context +from shared.sx.types import NIL, Island, Lambda + +# Build env with primitives +env = make_env() + +# --- Patch invoke and apply BEFORE anything else --- +# Test expressions create SX Lambdas that bootstrapped code calls via invoke/apply. +# Patch the module-level functions so all bootstrapped functions see them. +def _invoke(f, *args): + if isinstance(f, Lambda): + return trampoline(eval_expr([f] + list(args), env)) + return f(*args) +sx_ref.__dict__["invoke"] = _invoke + +# apply is used by swap! and other forms to call functions with arg lists +def _apply(f, args): + if isinstance(f, Lambda): + return trampoline(eval_expr([f] + list(args), env)) + return f(*args) +sx_ref.__dict__["apply"] = _apply + +# cons needs to handle tuples from Python *args (swap! passes &rest as tuple) +_orig_cons = sx_ref.PRIMITIVES.get("cons") +def _cons(x, c): + if isinstance(c, tuple): + c = list(c) + return [x] + (c or []) +sx_ref.__dict__["cons"] = _cons +sx_ref.PRIMITIVES["cons"] = _cons + +# Platform test functions +_suite_stack: list[str] = [] +_pass_count = 0 +_fail_count = 0 + +def _try_call(thunk): + try: + trampoline(eval_expr([thunk], env)) + return {"ok": True} + except Exception as e: + return {"ok": False, "error": str(e)} + +def _report_pass(name): + global _pass_count + _pass_count += 1 + ctx = " > ".join(_suite_stack) + print(f" PASS: {ctx} > {name}") + return NIL + +def _report_fail(name, error): + global _fail_count + _fail_count += 1 + ctx = " > ".join(_suite_stack) + print(f" FAIL: {ctx} > {name}: {error}") + return NIL + +def _push_suite(name): + _suite_stack.append(name) + print(f"{' ' * (len(_suite_stack)-1)}Suite: {name}") + return NIL + +def _pop_suite(): + if _suite_stack: + _suite_stack.pop() + return NIL + +env["try-call"] = _try_call +env["report-pass"] = _report_pass +env["report-fail"] = _report_fail +env["push-suite"] = _push_suite +env["pop-suite"] = _pop_suite + +# Signal platform primitives +env["signal?"] = sx_ref.is_signal +env["make-signal"] = sx_ref.make_signal +env["signal-value"] = sx_ref.signal_value +env["signal-set-value!"] = sx_ref.signal_set_value +env["signal-subscribers"] = sx_ref.signal_subscribers +env["signal-add-sub!"] = sx_ref.signal_add_sub +env["signal-remove-sub!"] = sx_ref.signal_remove_sub +env["signal-deps"] = sx_ref.signal_deps +env["signal-set-deps!"] = sx_ref.signal_set_deps +env["identical?"] = sx_ref.is_identical +env["island?"] = lambda x: isinstance(x, Island) + +# Scope primitives (used by signals.sx for reactive tracking) +env["scope-push!"] = scope_push +env["scope-pop!"] = scope_pop +env["context"] = sx_context + +# Bootstrapped signal functions from sx_ref.py +env["signal"] = sx_ref.signal +env["deref"] = sx_ref.deref +env["reset!"] = sx_ref.reset_b +env["swap!"] = sx_ref.swap_b +env["computed"] = sx_ref.computed +env["effect"] = sx_ref.effect +# batch has a bootstrapper issue with _batch_depth global variable access. +# Wrap it to work correctly in the test context. +def _batch(thunk): + sx_ref._batch_depth = getattr(sx_ref, '_batch_depth', 0) + 1 + _invoke(thunk) + sx_ref._batch_depth -= 1 + if sx_ref._batch_depth == 0: + queue = list(sx_ref._batch_queue) + sx_ref._batch_queue = [] + seen = [] + pending = [] + for s in queue: + for sub in sx_ref.signal_subscribers(s): + if sub not in seen: + seen.append(sub) + pending.append(sub) + for sub in pending: + sub() + return NIL +env["batch"] = _batch +env["notify-subscribers"] = sx_ref.notify_subscribers +env["flush-subscribers"] = sx_ref.flush_subscribers +env["dispose-computed"] = sx_ref.dispose_computed +env["with-island-scope"] = sx_ref.with_island_scope +env["register-in-scope"] = sx_ref.register_in_scope +env["invoke"] = _invoke +env["callable?"] = sx_ref.is_callable + +# Load test framework +with open(os.path.join(_HERE, "test-framework.sx")) as f: + for expr in parse_all(f.read()): + trampoline(eval_expr(expr, env)) + +# Run tests +print("=" * 60) +print("Running test-signals.sx") +print("=" * 60) + +with open(os.path.join(_HERE, "test-signals.sx")) as f: + for expr in parse_all(f.read()): + trampoline(eval_expr(expr, env)) + +print("=" * 60) +print(f"Results: {_pass_count} passed, {_fail_count} failed") +print("=" * 60) +sys.exit(1 if _fail_count > 0 else 0) diff --git a/shared/sx/ref/signals.sx b/shared/sx/ref/signals.sx index f7f6d11..bba2a02 100644 --- a/shared/sx/ref/signals.sx +++ b/shared/sx/ref/signals.sx @@ -9,6 +9,12 @@ ;; layer (adapter-dom.sx) subscribes DOM nodes to signals. The server ;; adapter (adapter-html.sx) reads signal values without subscribing. ;; +;; Reactive tracking and island lifecycle use the general scoped effects +;; system (scope-push!/scope-pop!/context) instead of separate globals. +;; Two scope names: +;; "sx-reactive" — tracking context for computed/effect dep discovery +;; "sx-island-scope" — island disposable collector +;; ;; Platform interface required: ;; (make-signal value) → Signal — create signal container ;; (signal? x) → boolean — type predicate @@ -20,10 +26,10 @@ ;; (signal-deps s) → list — dependency list (for computed) ;; (signal-set-deps! s deps) → void — set dependency list ;; -;; Global state required: -;; *tracking-context* → nil | Effect/Computed currently evaluating -;; (set-tracking-context! c) → void -;; (get-tracking-context) → context or nil +;; Scope-based tracking (replaces TrackingContext platform primitives): +;; (scope-push! "sx-reactive" {:deps (list) :notify fn}) → void +;; (scope-pop! "sx-reactive") → void +;; (context "sx-reactive" nil) → dict or nil ;; ;; Runtime callable dispatch: ;; (invoke f &rest args) → any — call f with args; handles both @@ -58,12 +64,14 @@ (fn ((s :as any)) (if (not (signal? s)) s ;; non-signal values pass through - (let ((ctx (get-tracking-context))) + (let ((ctx (context "sx-reactive" nil))) (when ctx ;; Register this signal as a dependency of the current context - (tracking-context-add-dep! ctx s) - ;; Subscribe the context to this signal - (signal-add-sub! s (tracking-context-notify-fn ctx))) + (let ((dep-list (get ctx "deps")) + (notify-fn (get ctx "notify"))) + (when (not (contains? dep-list s)) + (append! dep-list s) + (signal-add-sub! s notify-fn)))) (signal-value s))))) @@ -117,19 +125,18 @@ (signal-deps s)) (signal-set-deps! s (list)) - ;; Create tracking context for this computed - (let ((ctx (make-tracking-context recompute))) - (let ((prev (get-tracking-context))) - (set-tracking-context! ctx) - (let ((new-val (invoke compute-fn))) - (set-tracking-context! prev) - ;; Save discovered deps - (signal-set-deps! s (tracking-context-deps ctx)) - ;; Update value + notify downstream - (let ((old (signal-value s))) - (signal-set-value! s new-val) - (when (not (identical? old new-val)) - (notify-subscribers s))))))))) + ;; Push scope-based tracking context for this computed + (let ((ctx (dict "deps" (list) "notify" recompute))) + (scope-push! "sx-reactive" ctx) + (let ((new-val (invoke compute-fn))) + (scope-pop! "sx-reactive") + ;; Save discovered deps + (signal-set-deps! s (get ctx "deps")) + ;; Update value + notify downstream + (let ((old (signal-value s))) + (signal-set-value! s new-val) + (when (not (identical? old new-val)) + (notify-subscribers s)))))))) ;; Initial computation (recompute) @@ -163,16 +170,15 @@ deps) (set! deps (list)) - ;; Track new deps - (let ((ctx (make-tracking-context run-effect))) - (let ((prev (get-tracking-context))) - (set-tracking-context! ctx) - (let ((result (invoke effect-fn))) - (set-tracking-context! prev) - (set! deps (tracking-context-deps ctx)) - ;; If effect returns a function, it's the cleanup - (when (callable? result) - (set! cleanup-fn result))))))))) + ;; Push scope-based tracking context + (let ((ctx (dict "deps" (list) "notify" run-effect))) + (scope-push! "sx-reactive" ctx) + (let ((result (invoke effect-fn))) + (scope-pop! "sx-reactive") + (set! deps (get ctx "deps")) + ;; If effect returns a function, it's the cleanup + (when (callable? result) + (set! cleanup-fn result)))))))) ;; Initial run (run-effect) @@ -246,19 +252,13 @@ ;; -------------------------------------------------------------------------- -;; 9. Tracking context +;; 9. Reactive tracking context ;; -------------------------------------------------------------------------- ;; -;; A tracking context is an ephemeral object created during effect/computed -;; evaluation to discover signal dependencies. Platform must provide: -;; -;; (make-tracking-context notify-fn) → context -;; (tracking-context-deps ctx) → list of signals -;; (tracking-context-add-dep! ctx s) → void (adds s to ctx's dep list) -;; (tracking-context-notify-fn ctx) → the notify function -;; -;; These are platform primitives because the context is mutable state -;; that must be efficient (often a Set in the host language). +;; Tracking is now scope-based. computed/effect push a dict +;; {:deps (list) :notify fn} onto the "sx-reactive" scope stack via +;; scope-push!/scope-pop!. deref reads it via (context "sx-reactive" nil). +;; No platform primitives needed — uses the existing scope infrastructure. ;; -------------------------------------------------------------------------- @@ -284,25 +284,24 @@ ;; When an island is created, all signals, effects, and computeds created ;; within it are tracked. When the island is removed from the DOM, they ;; are all disposed. - -(define *island-scope* nil) +;; +;; Uses "sx-island-scope" scope name. The scope value is a collector +;; function (fn (disposable) ...) that appends to the island's disposer list. (define with-island-scope :effects [mutation] (fn ((scope-fn :as lambda) (body-fn :as lambda)) - (let ((prev *island-scope*)) - (set! *island-scope* scope-fn) - (let ((result (body-fn))) - (set! *island-scope* prev) - result)))) + (scope-push! "sx-island-scope" scope-fn) + (let ((result (body-fn))) + (scope-pop! "sx-island-scope") + result))) ;; Hook into signal/effect/computed creation for scope tracking. -;; The platform's make-signal should call (register-in-scope s) if -;; *island-scope* is non-nil. (define register-in-scope :effects [mutation] (fn ((disposable :as lambda)) - (when *island-scope* - (*island-scope* disposable)))) + (let ((collector (context "sx-island-scope" nil))) + (when collector + (invoke collector disposable))))) ;; ========================================================================== diff --git a/shared/sx/ref/sx_ref.py b/shared/sx/ref/sx_ref.py index e0f476a..d813b71 100644 --- a/shared/sx/ref/sx_ref.py +++ b/shared/sx/ref/sx_ref.py @@ -440,17 +440,6 @@ class _Signal: self.deps = [] -class _TrackingContext: - """Context for discovering signal dependencies.""" - __slots__ = ("notify_fn", "deps") - def __init__(self, notify_fn): - self.notify_fn = notify_fn - self.deps = [] - - -_tracking_context = None - - def make_signal(value): return _Signal(value) @@ -491,33 +480,6 @@ def signal_set_deps(s, deps): s.deps = list(deps) if isinstance(deps, list) else [] -def set_tracking_context(ctx): - global _tracking_context - _tracking_context = ctx - - -def get_tracking_context(): - global _tracking_context - return _tracking_context if _tracking_context is not None else NIL - - -def make_tracking_context(notify_fn): - return _TrackingContext(notify_fn) - - -def tracking_context_deps(ctx): - return ctx.deps if isinstance(ctx, _TrackingContext) else [] - - -def tracking_context_add_dep(ctx, s): - if isinstance(ctx, _TrackingContext) and s not in ctx.deps: - ctx.deps.append(s) - - -def tracking_context_notify_fn(ctx): - return ctx.notify_fn if isinstance(ctx, _TrackingContext) else NIL - - def invoke(f, *args): """Call f with args — handles both native callables and SX lambdas. @@ -3503,10 +3465,13 @@ def deref(s): if sx_truthy((not sx_truthy(is_signal(s)))): return s else: - ctx = get_tracking_context() + ctx = sx_context('sx-reactive', NIL) if sx_truthy(ctx): - tracking_context_add_dep(ctx, s) - signal_add_sub(s, tracking_context_notify_fn(ctx)) + dep_list = get(ctx, 'deps') + notify_fn = get(ctx, 'notify') + if sx_truthy((not sx_truthy(contains_p(dep_list, s)))): + dep_list.append(s) + signal_add_sub(s, notify_fn) return signal_value(s) # reset! @@ -3538,7 +3503,7 @@ def computed(compute_fn): recompute = _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)) + (lambda ctx: _sx_begin(scope_push('sx-reactive', ctx), (lambda new_val: _sx_begin(scope_pop('sx-reactive'), signal_set_deps(s, get(ctx, 'deps')), (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))))({'deps': [], 'notify': recompute}) )[-1]) recompute() register_in_scope(lambda : dispose_computed(s)) @@ -3550,7 +3515,7 @@ def effect(effect_fn): _cells['deps'] = [] _cells['disposed'] = False _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 = 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: _sx_begin(scope_push('sx-reactive', ctx), (lambda result: _sx_begin(scope_pop('sx-reactive'), _sx_cell_set(_cells, 'deps', get(ctx, 'deps')), (_sx_cell_set(_cells, 'cleanup_fn', result) if sx_truthy(is_callable(result)) else NIL)))(invoke(effect_fn))))({'deps': [], 'notify': run_effect})) if sx_truthy((not sx_truthy(_cells['disposed']))) else NIL) run_effect() dispose_fn = _sx_fn(lambda : ( _sx_cell_set(_cells, 'disposed', True), @@ -3610,21 +3575,18 @@ def dispose_computed(s): return signal_set_deps(s, []) return NIL -# *island-scope* -_island_scope = NIL - # with-island-scope def with_island_scope(scope_fn, body_fn): - prev = _island_scope - _island_scope = scope_fn + scope_push('sx-island-scope', scope_fn) result = body_fn() - _island_scope = prev + scope_pop('sx-island-scope') return result # register-in-scope def register_in_scope(disposable): - if sx_truthy(_island_scope): - return _island_scope(disposable) + collector = sx_context('sx-island-scope', NIL) + if sx_truthy(collector): + return invoke(collector, disposable) return NIL # with-marsh-scope diff --git a/shared/sx/ref/test-signals.sx b/shared/sx/ref/test-signals.sx index 3ae5695..189a1c3 100644 --- a/shared/sx/ref/test-signals.sx +++ b/shared/sx/ref/test-signals.sx @@ -171,3 +171,46 @@ (list "wrap" children)) (assert-equal (list "wrap" (list "a" "b")) (~wrapper "a" "b")))) + + +;; -------------------------------------------------------------------------- +;; Scope integration — reactive tracking uses scope-push!/scope-pop! +;; -------------------------------------------------------------------------- + +(defsuite "scope integration" + (deftest "deref outside reactive scope does not subscribe" + (let ((s (signal 42))) + ;; Reading outside any reactive context should not add subscribers + (assert-equal 42 (deref s)) + (assert-equal 0 (len (signal-subscribers s))))) + + (deftest "computed uses scope for tracking" + (let ((a (signal 1)) + (b (signal 2)) + (sum (computed (fn () (+ (deref a) (deref b)))))) + ;; Each signal should have exactly 1 subscriber (the computed's recompute) + (assert-equal 1 (len (signal-subscribers a))) + (assert-equal 1 (len (signal-subscribers b))) + ;; Verify computed value + (assert-equal 3 (deref sum)))) + + (deftest "nested effects with overlapping deps use scope correctly" + (let ((shared (signal 0)) + (inner-only (signal 0)) + (outer-count (signal 0)) + (inner-count (signal 0))) + ;; Outer effect tracks shared + (effect (fn () (do (deref shared) (swap! outer-count inc)))) + ;; Inner effect tracks shared AND inner-only + (effect (fn () (do (deref shared) (deref inner-only) (swap! inner-count inc)))) + ;; Both ran once + (assert-equal 1 (deref outer-count)) + (assert-equal 1 (deref inner-count)) + ;; Changing shared triggers both + (reset! shared 1) + (assert-equal 2 (deref outer-count)) + (assert-equal 2 (deref inner-count)) + ;; Changing inner-only triggers only inner + (reset! inner-only 1) + (assert-equal 2 (deref outer-count)) + (assert-equal 3 (deref inner-count)))))