Collapse reactive islands into scopes: replace TrackingContext and *island-scope* with scope-push!/scope-pop!/context

Reactive tracking (deref/computed/effect dep discovery) and island lifecycle
now use the general scoped effects system instead of parallel infrastructure.
Two scope names: "sx-reactive" for tracking context, "sx-island-scope" for
island disposable collection. Eliminates ~98 net lines: _TrackingContext class,
7 tracking context platform functions (Python + JS), *island-scope* global,
and corresponding RENAME_MAP entries. All 20 signal tests pass (17 original +
3 new scope integration tests), plus CEK/continuation/type tests clean.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 23:09:09 +00:00
parent 1765216335
commit dcc73a68d5
11 changed files with 330 additions and 268 deletions

View File

@@ -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() {

View File

@@ -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)))

View File

@@ -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",

View File

@@ -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"

View File

@@ -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

View File

@@ -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.

View File

@@ -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"

View File

@@ -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)

View File

@@ -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)))))
;; ==========================================================================

View File

@@ -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

View File

@@ -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)))))