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 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 isNil(x) { return x === NIL || x === null || x === undefined; }
function isSxTruthy(x) { return x !== false && !isNil(x); } function isSxTruthy(x) { return x !== false && !isNil(x); }
@@ -61,13 +61,6 @@
} }
SxSignal.prototype._signal = true; SxSignal.prototype._signal = true;
function TrackingCtx(notifyFn) {
this.notifyFn = notifyFn;
this.deps = [];
}
var _trackingContext = null;
function Macro(params, restParam, body, closure, name) { function Macro(params, restParam, body, closure, name) {
this.params = params; this.params = params;
this.restParam = restParam; 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 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 signalDeps(s) { return s.deps.slice(); }
function signalSetDeps(s, deps) { s.deps = Array.isArray(deps) ? 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. // invoke — call any callable (native fn or SX lambda) with args.
// Transpiled code emits direct calls f(args) which fail on SX lambdas // 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 before = trampoline(evalExpr(first(args), env));
var body = trampoline(evalExpr(nth(args, 1), env)); var body = trampoline(evalExpr(nth(args, 1), env));
var after = trampoline(evalExpr(nth(args, 2), env)); var after = trampoline(evalExpr(nth(args, 2), env));
callThunk(before, env); return dynamicWindCall(before, body, after, env);
pushWind(before, after);
return (function() {
var result = callThunk(body, env);
popWind();
callThunk(after, env);
return result;
})();
})(); }; })(); };
// sf-scope // sf-scope
@@ -1936,7 +1916,7 @@ return result; }, args);
// render-to-dom // render-to-dom
var renderToDom = function(expr, env, ns) { setRenderActiveB(true); 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 // render-dom-list
var renderDomList = function(expr, env, ns) { return (function() { 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() { 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); var comp = envGet(env, name);
return (isSxTruthy(isComponent(comp)) ? renderDomComponent(comp, args, env, ns) : renderDomUnknownComponent(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)); var sigOrVal = trampoline(evalExpr(first(args), env));
return (isSxTruthy(isSignal(sigOrVal)) ? reactiveText(sigOrVal) : createTextNode((String(deref(sigOrVal))))); return (isSxTruthy(isSignal(sigOrVal)) ? reactiveText(sigOrVal) : createTextNode((String(deref(sigOrVal)))));
})() : renderToDom(trampoline(evalExpr(expr, env)), env, ns)))))))))))))); })() : 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() { })() : (isSxTruthy((attrName == "key")) ? (function() {
var attrVal = trampoline(evalExpr(attrExpr, env)); var attrVal = trampoline(evalExpr(attrExpr, env));
return domSetAttr(el, "key", (String(attrVal))); 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)); 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 (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)); return assoc(state, "skip", true, "i", (get(state, "i") + 1));
})() : ((isSxTruthy(!isSxTruthy(contains(VOID_ELEMENTS, tag))) ? (function() { })() : ((isSxTruthy(!isSxTruthy(contains(VOID_ELEMENTS, tag))) ? (function() {
var child = renderToDom(arg, env, newNs); 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))))); })() : NIL), assoc(state, "i", (get(state, "i") + 1)))));
})(); }, {["i"]: 0, ["skip"]: false}, args); })(); }, {["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() { { 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); }; var isRenderDomForm = function(name) { return contains(RENDER_DOM_FORMS, name); };
// dispatch-render-form // 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 marker = createComment("r-if");
var currentNodes = []; var currentNodes = [];
var initialResult = NIL; var initialResult = NIL;
@@ -2089,7 +2069,7 @@ return (function() { var _m = typeOf(expr); if (_m == "nil") return createFragme
})() : (function() { })() : (function() {
var condVal = trampoline(evalExpr(nth(expr, 1), env)); 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())); 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 marker = createComment("r-when");
var currentNodes = []; var currentNodes = [];
var initialResult = NIL; var initialResult = NIL;
@@ -2116,7 +2096,7 @@ return (function() { var _m = typeOf(expr); if (_m == "nil") return createFragme
var frag = createFragment(); 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)); } } { 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; return frag;
})())) : (isSxTruthy((name == "cond")) ? (isSxTruthy(_islandScope) ? (function() { })())) : (isSxTruthy((name == "cond")) ? (isSxTruthy(sxContext("sx-island-scope", NIL)) ? (function() {
var marker = createComment("r-cond"); var marker = createComment("r-cond");
var currentNodes = []; var currentNodes = [];
var initialResult = NIL; var initialResult = NIL;
@@ -2162,7 +2142,7 @@ return (function() { var _m = typeOf(expr); if (_m == "nil") return createFragme
return frag; return frag;
})()) : (isSxTruthy(isDefinitionForm(name)) ? (trampoline(evalExpr(expr, env)), createFragment()) : (isSxTruthy((name == "map")) ? (function() { })()) : (isSxTruthy(isDefinitionForm(name)) ? (trampoline(evalExpr(expr, env)), createFragment()) : (isSxTruthy((name == "map")) ? (function() {
var collExpr = nth(expr, 2); 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 f = trampoline(evalExpr(nth(expr, 1), env));
var sig = trampoline(evalExpr(nth(collExpr, 1), env)); var sig = trampoline(evalExpr(nth(collExpr, 1), env));
return (isSxTruthy(isSignal(sig)) ? reactiveList(f, sig, env, ns) : (function() { return (isSxTruthy(isSignal(sig)) ? reactiveList(f, sig, env, ns) : (function() {
@@ -2490,15 +2470,13 @@ return (isSxTruthy(testFn()) ? (function() {
domSetAttr(container, "data-sx-boundary", "true"); domSetAttr(container, "data-sx-boundary", "true");
effect(function() { deref(retryVersion); effect(function() { deref(retryVersion);
domSetProp(container, "innerHTML", ""); domSetProp(container, "innerHTML", "");
return (function() { scopePush("sx-island-scope", NIL);
var savedScope = _islandScope; return tryCatch(function() { (function() {
_islandScope = NIL;
return tryCatch(function() { (function() {
var frag = createFragment(); var frag = createFragment();
{ var _c = bodyExprs; for (var _i = 0; _i < _c.length; _i++) { var child = _c[_i]; domAppend(frag, renderToDom(child, env, ns)); } } { 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 domAppend(container, frag);
})(); })();
return (_islandScope = savedScope); }, function(err) { _islandScope = savedScope; return scopePop("sx-island-scope"); }, function(err) { scopePop("sx-island-scope");
return (function() { return (function() {
var fallbackFn = trampoline(evalExpr(fallbackExpr, env)); var fallbackFn = trampoline(evalExpr(fallbackExpr, env));
var retryFn = function() { return swap_b(retryVersion, function(n) { return (n + 1); }); }; 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)); var fallbackDom = (isSxTruthy(isLambda(fallbackFn)) ? renderLambdaDom(fallbackFn, [err, retryFn], env, ns) : renderToDom(apply(fallbackFn, [err, retryFn]), env, ns));
return domAppend(container, fallbackDom); return domAppend(container, fallbackDom);
})(); })();
})(); }); })(); }); });
})(); });
return container; return container;
})(); }; })(); };
@@ -4272,10 +4249,13 @@ callExpr.push(dictGet(kwargs, k)); } }
// deref // deref
var deref = function(s) { return (isSxTruthy(!isSxTruthy(isSignal(s))) ? s : (function() { var deref = function(s) { return (isSxTruthy(!isSxTruthy(isSignal(s))) ? s : (function() {
var ctx = getTrackingContext(); var ctx = sxContext("sx-reactive", NIL);
if (isSxTruthy(ctx)) { if (isSxTruthy(ctx)) {
trackingContextAddDep(ctx, s); (function() {
signalAddSub(s, trackingContextNotifyFn(ctx)); 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); 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); } } var recompute = function() { { var _c = signalDeps(s); for (var _i = 0; _i < _c.length; _i++) { var dep = _c[_i]; signalRemoveSub(dep, recompute); } }
signalSetDeps(s, []); signalSetDeps(s, []);
return (function() { return (function() {
var ctx = makeTrackingContext(recompute); var ctx = {["deps"]: [], ["notify"]: recompute};
return (function() { scopePush("sx-reactive", ctx);
var prev = getTrackingContext();
setTrackingContext(ctx);
return (function() { return (function() {
var newVal = invoke(computeFn); var newVal = invoke(computeFn);
setTrackingContext(prev); scopePop("sx-reactive");
signalSetDeps(s, trackingContextDeps(ctx)); signalSetDeps(s, get(ctx, "deps"));
return (function() { return (function() {
var old = signalValue(s); var old = signalValue(s);
signalSetValue(s, newVal); signalSetValue(s, newVal);
return (isSxTruthy(!isSxTruthy(isIdentical(old, newVal))) ? notifySubscribers(s) : NIL); return (isSxTruthy(!isSxTruthy(isIdentical(old, newVal))) ? notifySubscribers(s) : NIL);
})(); })();
})(); })();
})();
})(); }; })(); };
recompute(); recompute();
registerInScope(function() { return disposeComputed(s); }); registerInScope(function() { return disposeComputed(s); });
@@ -4331,17 +4308,14 @@ return (function() {
var cleanupFn = NIL; var cleanupFn = NIL;
return (function() { 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 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); var ctx = {["deps"]: [], ["notify"]: runEffect};
return (function() { scopePush("sx-reactive", ctx);
var prev = getTrackingContext();
setTrackingContext(ctx);
return (function() { return (function() {
var result = invoke(effectFn); var result = invoke(effectFn);
setTrackingContext(prev); scopePop("sx-reactive");
deps = trackingContextDeps(ctx); deps = get(ctx, "deps");
return (isSxTruthy(isCallable(result)) ? (cleanupFn = result) : NIL); return (isSxTruthy(isCallable(result)) ? (cleanupFn = result) : NIL);
})(); })();
})();
})()) : NIL); }; })()) : NIL); };
runEffect(); runEffect();
return (function() { return (function() {
@@ -4390,22 +4364,19 @@ return (isSxTruthy((_batchDepth == 0)) ? (function() {
// dispose-computed // dispose-computed
var disposeComputed = function(s) { return (isSxTruthy(isSignal(s)) ? (forEach(function(dep) { return signalRemoveSub(dep, NIL); }, signalDeps(s)), signalSetDeps(s, [])) : NIL); }; 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 // with-island-scope
var withIslandScope = function(scopeFn, bodyFn) { return (function() { var withIslandScope = function(scopeFn, bodyFn) { scopePush("sx-island-scope", scopeFn);
var prev = _islandScope; return (function() {
_islandScope = scopeFn;
return (function() {
var result = bodyFn(); var result = bodyFn();
_islandScope = prev; scopePop("sx-island-scope");
return result; return result;
})();
})(); }; })(); };
// register-in-scope // 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 // with-marsh-scope
var withMarshScope = function(marshEl, bodyFn) { return (function() { var withMarshScope = function(marshEl, bodyFn) { return (function() {

View File

@@ -59,7 +59,7 @@
;; Signal → reactive text in island scope, deref outside ;; Signal → reactive text in island scope, deref outside
:else :else
(if (signal? expr) (if (signal? expr)
(if *island-scope* (if (context "sx-island-scope" nil)
(reactive-text expr) (reactive-text expr)
(create-text-node (str (deref expr)))) (create-text-node (str (deref expr))))
(create-text-node (str expr)))))) (create-text-node (str expr))))))
@@ -143,7 +143,7 @@
(render-dom-element name args env ns) (render-dom-element name args env ns)
;; deref in island scope → reactive text node ;; 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)))) (let ((sig-or-val (trampoline (eval-expr (first args) env))))
(if (signal? sig-or-val) (if (signal? sig-or-val)
(reactive-text sig-or-val) (reactive-text sig-or-val)
@@ -215,7 +215,7 @@
;; Inside island scope: reactive attribute binding. ;; Inside island scope: reactive attribute binding.
;; The effect tracks signal deps automatically — if none ;; The effect tracks signal deps automatically — if none
;; are deref'd, it fires once and never again (safe). ;; are deref'd, it fires once and never again (safe).
*island-scope* (context "sx-island-scope" nil)
(reactive-attr el attr-name (reactive-attr el attr-name
(fn () (trampoline (eval-expr attr-expr env)))) (fn () (trampoline (eval-expr attr-expr env))))
;; Static attribute (outside islands) ;; Static attribute (outside islands)
@@ -237,7 +237,7 @@
(let ((child (render-to-dom arg env new-ns))) (let ((child (render-to-dom arg env new-ns)))
(cond (cond
;; Reactive spread: track signal deps, update attrs on change ;; 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))) (reactive-spread el (fn () (render-to-dom arg env new-ns)))
;; Static spread: already emitted via provide, skip ;; Static spread: already emitted via provide, skip
(spread? child) nil (spread? child) nil
@@ -392,7 +392,7 @@
(cond (cond
;; if — reactive inside islands (re-renders when signal deps change) ;; if — reactive inside islands (re-renders when signal deps change)
(= name "if") (= name "if")
(if *island-scope* (if (context "sx-island-scope" nil)
(let ((marker (create-comment "r-if")) (let ((marker (create-comment "r-if"))
(current-nodes (list)) (current-nodes (list))
(initial-result nil)) (initial-result nil))
@@ -440,7 +440,7 @@
;; when — reactive inside islands ;; when — reactive inside islands
(= name "when") (= name "when")
(if *island-scope* (if (context "sx-island-scope" nil)
(let ((marker (create-comment "r-when")) (let ((marker (create-comment "r-when"))
(current-nodes (list)) (current-nodes (list))
(initial-result nil)) (initial-result nil))
@@ -486,7 +486,7 @@
;; cond — reactive inside islands ;; cond — reactive inside islands
(= name "cond") (= name "cond")
(if *island-scope* (if (context "sx-island-scope" nil)
(let ((marker (create-comment "r-cond")) (let ((marker (create-comment "r-cond"))
(current-nodes (list)) (current-nodes (list))
(initial-result nil)) (initial-result nil))
@@ -563,7 +563,7 @@
;; map — reactive-list when mapping over a signal inside an island ;; map — reactive-list when mapping over a signal inside an island
(= name "map") (= name "map")
(let ((coll-expr (nth expr 2))) (let ((coll-expr (nth expr 2)))
(if (and *island-scope* (if (and (context "sx-island-scope" nil)
(= (type-of coll-expr) "list") (= (type-of coll-expr) "list")
(> (len coll-expr) 1) (> (len coll-expr) 1)
(= (type-of (first coll-expr)) "symbol") (= (type-of (first coll-expr)) "symbol")
@@ -1168,7 +1168,7 @@
(dom-set-attr container "data-sx-boundary" "true") (dom-set-attr container "data-sx-boundary" "true")
;; The entire body is rendered inside ONE effect + try-catch. ;; 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 ;; paths — their signal reads become direct deref calls tracked by THIS
;; effect. Errors from signal changes throw synchronously within try-catch. ;; effect. Errors from signal changes throw synchronously within try-catch.
;; The error boundary's own effect handles all reactivity for its subtree. ;; The error boundary's own effect handles all reactivity for its subtree.
@@ -1179,31 +1179,30 @@
;; Clear container ;; Clear container
(dom-set-prop container "innerHTML" "") (dom-set-prop container "innerHTML" "")
;; Save and clear island scope BEFORE try-catch so it can be ;; Push nil island scope to suppress reactive rendering in body.
;; restored in both success and error paths. ;; Pop in both success and error paths.
(let ((saved-scope *island-scope*)) (scope-push! "sx-island-scope" nil)
(set! *island-scope* nil) (try-catch
(try-catch (fn ()
(fn () ;; Body renders statically — signal reads tracked by THIS effect,
;; Body renders statically — signal reads tracked by THIS effect, ;; throws propagate to our try-catch.
;; throws propagate to our try-catch. (let ((frag (create-fragment)))
(let ((frag (create-fragment))) (for-each
(for-each (fn (child)
(fn (child) (dom-append frag (render-to-dom child env ns)))
(dom-append frag (render-to-dom child env ns))) body-exprs)
body-exprs) (dom-append container frag))
(dom-append container frag)) (scope-pop! "sx-island-scope"))
(set! *island-scope* saved-scope)) (fn (err)
(fn (err) ;; Pop scope first, then render fallback
;; Restore scope first, then render fallback (scope-pop! "sx-island-scope")
(set! *island-scope* saved-scope)
(let ((fallback-fn (trampoline (eval-expr fallback-expr env))) (let ((fallback-fn (trampoline (eval-expr fallback-expr env)))
(retry-fn (fn () (swap! retry-version (fn (n) (+ n 1)))))) (retry-fn (fn () (swap! retry-version (fn (n) (+ n 1))))))
(let ((fallback-dom (let ((fallback-dom
(if (lambda? fallback-fn) (if (lambda? fallback-fn)
(render-lambda-dom fallback-fn (list err retry-fn) env ns) (render-lambda-dom fallback-fn (list err retry-fn) env ns)
(render-to-dom (apply 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))) container)))

View File

@@ -165,12 +165,6 @@ class PyEmitter:
"signal-remove-sub!": "signal_remove_sub", "signal-remove-sub!": "signal_remove_sub",
"signal-deps": "signal_deps", "signal-deps": "signal_deps",
"signal-set-deps!": "signal_set_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", "identical?": "is_identical",
"notify-subscribers": "notify_subscribers", "notify-subscribers": "notify_subscribers",
"flush-subscribers": "flush_subscribers", "flush-subscribers": "flush_subscribers",
@@ -179,7 +173,6 @@ class PyEmitter:
"register-in-scope": "register_in_scope", "register-in-scope": "register_in_scope",
"*batch-depth*": "_batch_depth", "*batch-depth*": "_batch_depth",
"*batch-queue*": "_batch_queue", "*batch-queue*": "_batch_queue",
"*island-scope*": "_island_scope",
"*store-registry*": "_store_registry", "*store-registry*": "_store_registry",
"def-store": "def_store", "def-store": "def_store",
"use-store": "use_store", "use-store": "use_store",

View File

@@ -87,12 +87,6 @@
"signal-remove-sub!" "signalRemoveSub" "signal-remove-sub!" "signalRemoveSub"
"signal-deps" "signalDeps" "signal-deps" "signalDeps"
"signal-set-deps!" "signalSetDeps" "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" "identical?" "isIdentical"
"notify-subscribers" "notifySubscribers" "notify-subscribers" "notifySubscribers"
"flush-subscribers" "flushSubscribers" "flush-subscribers" "flushSubscribers"
@@ -101,7 +95,6 @@
"register-in-scope" "registerInScope" "register-in-scope" "registerInScope"
"*batch-depth*" "_batchDepth" "*batch-depth*" "_batchDepth"
"*batch-queue*" "_batchQueue" "*batch-queue*" "_batchQueue"
"*island-scope*" "_islandScope"
"*store-registry*" "_storeRegistry" "*store-registry*" "_storeRegistry"
"def-store" "defStore" "def-store" "defStore"
"use-store" "useStore" "use-store" "useStore"

View File

@@ -858,13 +858,6 @@ PREAMBLE = '''\
} }
SxSignal.prototype._signal = true; SxSignal.prototype._signal = true;
function TrackingCtx(notifyFn) {
this.notifyFn = notifyFn;
this.deps = [];
}
var _trackingContext = null;
function Macro(params, restParam, body, closure, name) { function Macro(params, restParam, body, closure, name) {
this.params = params; this.params = params;
this.restParam = restParam; 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 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 signalDeps(s) { return s.deps.slice(); }
function signalSetDeps(s, deps) { s.deps = Array.isArray(deps) ? 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. // invoke — call any callable (native fn or SX lambda) with args.
// Transpiled code emits direct calls f(args) which fail on SX lambdas // Transpiled code emits direct calls f(args) which fail on SX lambdas

View File

@@ -481,17 +481,6 @@ class _Signal:
self.deps = [] 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): def make_signal(value):
return _Signal(value) return _Signal(value)
@@ -532,33 +521,6 @@ def signal_set_deps(s, deps):
s.deps = list(deps) if isinstance(deps, list) else [] 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): def invoke(f, *args):
"""Call f with args — handles both native callables and SX lambdas. """Call f with args — handles both native callables and SX lambdas.

View File

@@ -84,12 +84,6 @@
"signal-remove-sub!" "signal_remove_sub" "signal-remove-sub!" "signal_remove_sub"
"signal-deps" "signal_deps" "signal-deps" "signal_deps"
"signal-set-deps!" "signal_set_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" "identical?" "is_identical"
"notify-subscribers" "notify_subscribers" "notify-subscribers" "notify_subscribers"
"flush-subscribers" "flush_subscribers" "flush-subscribers" "flush_subscribers"
@@ -98,7 +92,6 @@
"register-in-scope" "register_in_scope" "register-in-scope" "register_in_scope"
"*batch-depth*" "_batch_depth" "*batch-depth*" "_batch_depth"
"*batch-queue*" "_batch_queue" "*batch-queue*" "_batch_queue"
"*island-scope*" "_island_scope"
"*store-registry*" "_store_registry" "*store-registry*" "_store_registry"
"def-store" "def_store" "def-store" "def_store"
"use-store" "use_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 ;; layer (adapter-dom.sx) subscribes DOM nodes to signals. The server
;; adapter (adapter-html.sx) reads signal values without subscribing. ;; 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: ;; Platform interface required:
;; (make-signal value) → Signal — create signal container ;; (make-signal value) → Signal — create signal container
;; (signal? x) → boolean — type predicate ;; (signal? x) → boolean — type predicate
@@ -20,10 +26,10 @@
;; (signal-deps s) → list — dependency list (for computed) ;; (signal-deps s) → list — dependency list (for computed)
;; (signal-set-deps! s deps) → void — set dependency list ;; (signal-set-deps! s deps) → void — set dependency list
;; ;;
;; Global state required: ;; Scope-based tracking (replaces TrackingContext platform primitives):
;; *tracking-context* → nil | Effect/Computed currently evaluating ;; (scope-push! "sx-reactive" {:deps (list) :notify fn}) → void
;; (set-tracking-context! c) → void ;; (scope-pop! "sx-reactive") → void
;; (get-tracking-context) → context or nil ;; (context "sx-reactive" nil) → dict or nil
;; ;;
;; Runtime callable dispatch: ;; Runtime callable dispatch:
;; (invoke f &rest args) → any — call f with args; handles both ;; (invoke f &rest args) → any — call f with args; handles both
@@ -58,12 +64,14 @@
(fn ((s :as any)) (fn ((s :as any))
(if (not (signal? s)) (if (not (signal? s))
s ;; non-signal values pass through s ;; non-signal values pass through
(let ((ctx (get-tracking-context))) (let ((ctx (context "sx-reactive" nil)))
(when ctx (when ctx
;; Register this signal as a dependency of the current context ;; Register this signal as a dependency of the current context
(tracking-context-add-dep! ctx s) (let ((dep-list (get ctx "deps"))
;; Subscribe the context to this signal (notify-fn (get ctx "notify")))
(signal-add-sub! s (tracking-context-notify-fn ctx))) (when (not (contains? dep-list s))
(append! dep-list s)
(signal-add-sub! s notify-fn))))
(signal-value s))))) (signal-value s)))))
@@ -117,19 +125,18 @@
(signal-deps s)) (signal-deps s))
(signal-set-deps! s (list)) (signal-set-deps! s (list))
;; Create tracking context for this computed ;; Push scope-based tracking context for this computed
(let ((ctx (make-tracking-context recompute))) (let ((ctx (dict "deps" (list) "notify" recompute)))
(let ((prev (get-tracking-context))) (scope-push! "sx-reactive" ctx)
(set-tracking-context! ctx) (let ((new-val (invoke compute-fn)))
(let ((new-val (invoke compute-fn))) (scope-pop! "sx-reactive")
(set-tracking-context! prev) ;; Save discovered deps
;; Save discovered deps (signal-set-deps! s (get ctx "deps"))
(signal-set-deps! s (tracking-context-deps ctx)) ;; Update value + notify downstream
;; Update value + notify downstream (let ((old (signal-value s)))
(let ((old (signal-value s))) (signal-set-value! s new-val)
(signal-set-value! s new-val) (when (not (identical? old new-val))
(when (not (identical? old new-val)) (notify-subscribers s))))))))
(notify-subscribers s)))))))))
;; Initial computation ;; Initial computation
(recompute) (recompute)
@@ -163,16 +170,15 @@
deps) deps)
(set! deps (list)) (set! deps (list))
;; Track new deps ;; Push scope-based tracking context
(let ((ctx (make-tracking-context run-effect))) (let ((ctx (dict "deps" (list) "notify" run-effect)))
(let ((prev (get-tracking-context))) (scope-push! "sx-reactive" ctx)
(set-tracking-context! ctx) (let ((result (invoke effect-fn)))
(let ((result (invoke effect-fn))) (scope-pop! "sx-reactive")
(set-tracking-context! prev) (set! deps (get ctx "deps"))
(set! deps (tracking-context-deps ctx)) ;; If effect returns a function, it's the cleanup
;; If effect returns a function, it's the cleanup (when (callable? result)
(when (callable? result) (set! cleanup-fn result))))))))
(set! cleanup-fn result)))))))))
;; Initial run ;; Initial run
(run-effect) (run-effect)
@@ -246,19 +252,13 @@
;; -------------------------------------------------------------------------- ;; --------------------------------------------------------------------------
;; 9. Tracking context ;; 9. Reactive tracking context
;; -------------------------------------------------------------------------- ;; --------------------------------------------------------------------------
;; ;;
;; A tracking context is an ephemeral object created during effect/computed ;; Tracking is now scope-based. computed/effect push a dict
;; evaluation to discover signal dependencies. Platform must provide: ;; {:deps (list) :notify fn} onto the "sx-reactive" scope stack via
;; ;; scope-push!/scope-pop!. deref reads it via (context "sx-reactive" nil).
;; (make-tracking-context notify-fn) → context ;; No platform primitives needed — uses the existing scope infrastructure.
;; (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).
;; -------------------------------------------------------------------------- ;; --------------------------------------------------------------------------
@@ -284,25 +284,24 @@
;; When an island is created, all signals, effects, and computeds created ;; When an island is created, all signals, effects, and computeds created
;; within it are tracked. When the island is removed from the DOM, they ;; within it are tracked. When the island is removed from the DOM, they
;; are all disposed. ;; 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] (define with-island-scope :effects [mutation]
(fn ((scope-fn :as lambda) (body-fn :as lambda)) (fn ((scope-fn :as lambda) (body-fn :as lambda))
(let ((prev *island-scope*)) (scope-push! "sx-island-scope" scope-fn)
(set! *island-scope* scope-fn) (let ((result (body-fn)))
(let ((result (body-fn))) (scope-pop! "sx-island-scope")
(set! *island-scope* prev) result)))
result))))
;; Hook into signal/effect/computed creation for scope tracking. ;; 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] (define register-in-scope :effects [mutation]
(fn ((disposable :as lambda)) (fn ((disposable :as lambda))
(when *island-scope* (let ((collector (context "sx-island-scope" nil)))
(*island-scope* disposable)))) (when collector
(invoke collector disposable)))))
;; ========================================================================== ;; ==========================================================================

View File

@@ -440,17 +440,6 @@ class _Signal:
self.deps = [] 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): def make_signal(value):
return _Signal(value) return _Signal(value)
@@ -491,33 +480,6 @@ def signal_set_deps(s, deps):
s.deps = list(deps) if isinstance(deps, list) else [] 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): def invoke(f, *args):
"""Call f with args — handles both native callables and SX lambdas. """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)))): if sx_truthy((not sx_truthy(is_signal(s)))):
return s return s
else: else:
ctx = get_tracking_context() ctx = sx_context('sx-reactive', NIL)
if sx_truthy(ctx): if sx_truthy(ctx):
tracking_context_add_dep(ctx, s) dep_list = get(ctx, 'deps')
signal_add_sub(s, tracking_context_notify_fn(ctx)) 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) return signal_value(s)
# reset! # reset!
@@ -3538,7 +3503,7 @@ def computed(compute_fn):
recompute = _sx_fn(lambda : ( recompute = _sx_fn(lambda : (
for_each(lambda dep: signal_remove_sub(dep, recompute), signal_deps(s)), for_each(lambda dep: signal_remove_sub(dep, recompute), signal_deps(s)),
signal_set_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]) )[-1])
recompute() recompute()
register_in_scope(lambda : dispose_computed(s)) register_in_scope(lambda : dispose_computed(s))
@@ -3550,7 +3515,7 @@ def effect(effect_fn):
_cells['deps'] = [] _cells['deps'] = []
_cells['disposed'] = False _cells['disposed'] = False
_cells['cleanup_fn'] = NIL _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() run_effect()
dispose_fn = _sx_fn(lambda : ( dispose_fn = _sx_fn(lambda : (
_sx_cell_set(_cells, 'disposed', True), _sx_cell_set(_cells, 'disposed', True),
@@ -3610,21 +3575,18 @@ def dispose_computed(s):
return signal_set_deps(s, []) return signal_set_deps(s, [])
return NIL return NIL
# *island-scope*
_island_scope = NIL
# with-island-scope # with-island-scope
def with_island_scope(scope_fn, body_fn): def with_island_scope(scope_fn, body_fn):
prev = _island_scope scope_push('sx-island-scope', scope_fn)
_island_scope = scope_fn
result = body_fn() result = body_fn()
_island_scope = prev scope_pop('sx-island-scope')
return result return result
# register-in-scope # register-in-scope
def register_in_scope(disposable): def register_in_scope(disposable):
if sx_truthy(_island_scope): collector = sx_context('sx-island-scope', NIL)
return _island_scope(disposable) if sx_truthy(collector):
return invoke(collector, disposable)
return NIL return NIL
# with-marsh-scope # with-marsh-scope

View File

@@ -171,3 +171,46 @@
(list "wrap" children)) (list "wrap" children))
(assert-equal (list "wrap" (list "a" "b")) (assert-equal (list "wrap" (list "a" "b"))
(~wrapper "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)))))