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:
@@ -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)))
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
160
shared/sx/ref/run_signal_tests.py
Normal file
160
shared/sx/ref/run_signal_tests.py
Normal 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)
|
||||
@@ -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)))))
|
||||
|
||||
|
||||
;; ==========================================================================
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)))))
|
||||
|
||||
Reference in New Issue
Block a user