Add signal test suite (17/17) and Island type to evaluator
test-signals.sx: 17 tests covering signal basics (create, deref, reset!, swap!), computed (derive, update, chain), effects (run, re-run, dispose, cleanup), batch (deferred deduped notifications), and defisland (create, call, children). types.py: Island dataclass mirroring Component but for reactive boundaries. evaluator.py: sf_defisland special form, Island in call dispatch. run.py: Signal platform primitives (make-signal, tracking context, etc) and native effect/computed/batch implementations that bridge Lambda calls across the Python↔SX boundary. signals.sx: Updated batch to deduplicate subscribers across signals. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -33,7 +33,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from .types import Component, Continuation, HandlerDef, Keyword, Lambda, Macro, NIL, PageDef, RelationDef, Symbol, _ShiftSignal
|
from .types import Component, Continuation, HandlerDef, Island, Keyword, Lambda, Macro, NIL, PageDef, RelationDef, Symbol, _ShiftSignal
|
||||||
from .primitives import _PRIMITIVES
|
from .primitives import _PRIMITIVES
|
||||||
|
|
||||||
|
|
||||||
@@ -147,13 +147,13 @@ def _eval(expr: Any, env: dict[str, Any]) -> Any:
|
|||||||
fn = _trampoline(_eval(head, env))
|
fn = _trampoline(_eval(head, env))
|
||||||
args = [_trampoline(_eval(a, env)) for a in expr[1:]]
|
args = [_trampoline(_eval(a, env)) for a in expr[1:]]
|
||||||
|
|
||||||
if callable(fn) and not isinstance(fn, (Lambda, Component)):
|
if callable(fn) and not isinstance(fn, (Lambda, Component, Island)):
|
||||||
return fn(*args)
|
return fn(*args)
|
||||||
|
|
||||||
if isinstance(fn, Lambda):
|
if isinstance(fn, Lambda):
|
||||||
return _call_lambda(fn, args, env)
|
return _call_lambda(fn, args, env)
|
||||||
|
|
||||||
if isinstance(fn, Component):
|
if isinstance(fn, (Component, Island)):
|
||||||
return _call_component(fn, expr[1:], env)
|
return _call_component(fn, expr[1:], env)
|
||||||
|
|
||||||
raise EvalError(f"Not callable: {fn!r}")
|
raise EvalError(f"Not callable: {fn!r}")
|
||||||
@@ -555,6 +555,51 @@ def _sf_defcomp(expr: list, env: dict) -> Component:
|
|||||||
return comp
|
return comp
|
||||||
|
|
||||||
|
|
||||||
|
def _sf_defisland(expr: list, env: dict) -> Island:
|
||||||
|
"""``(defisland ~name (&key ...) body)``"""
|
||||||
|
if len(expr) < 4:
|
||||||
|
raise EvalError("defisland requires name, params, and body")
|
||||||
|
name_sym = expr[1]
|
||||||
|
if not isinstance(name_sym, Symbol):
|
||||||
|
raise EvalError(f"defisland name must be symbol, got {type(name_sym).__name__}")
|
||||||
|
comp_name = name_sym.name.lstrip("~")
|
||||||
|
|
||||||
|
params_expr = expr[2]
|
||||||
|
if not isinstance(params_expr, list):
|
||||||
|
raise EvalError("defisland params must be a list")
|
||||||
|
|
||||||
|
params: list[str] = []
|
||||||
|
has_children = False
|
||||||
|
in_key = False
|
||||||
|
for p in params_expr:
|
||||||
|
if isinstance(p, Symbol):
|
||||||
|
if p.name == "&key":
|
||||||
|
in_key = True
|
||||||
|
continue
|
||||||
|
if p.name == "&rest":
|
||||||
|
has_children = True
|
||||||
|
continue
|
||||||
|
if in_key or has_children:
|
||||||
|
if not has_children:
|
||||||
|
params.append(p.name)
|
||||||
|
else:
|
||||||
|
params.append(p.name)
|
||||||
|
elif isinstance(p, str):
|
||||||
|
params.append(p)
|
||||||
|
|
||||||
|
body = expr[-1]
|
||||||
|
|
||||||
|
island = Island(
|
||||||
|
name=comp_name,
|
||||||
|
params=params,
|
||||||
|
has_children=has_children,
|
||||||
|
body=body,
|
||||||
|
closure=dict(env),
|
||||||
|
)
|
||||||
|
env[name_sym.name] = island
|
||||||
|
return island
|
||||||
|
|
||||||
|
|
||||||
def _defcomp_kwarg(expr: list, key: str, default: str) -> str:
|
def _defcomp_kwarg(expr: list, key: str, default: str) -> str:
|
||||||
"""Extract a keyword annotation from defcomp, e.g. :affinity :client."""
|
"""Extract a keyword annotation from defcomp, e.g. :affinity :client."""
|
||||||
# Scan from index 3 to second-to-last for :key value pairs
|
# Scan from index 3 to second-to-last for :key value pairs
|
||||||
@@ -592,7 +637,7 @@ def _sf_thread_first(expr: list, env: dict) -> Any:
|
|||||||
else:
|
else:
|
||||||
fn = _trampoline(_eval(form, env))
|
fn = _trampoline(_eval(form, env))
|
||||||
args = [result]
|
args = [result]
|
||||||
if callable(fn) and not isinstance(fn, (Lambda, Component)):
|
if callable(fn) and not isinstance(fn, (Lambda, Component, Island)):
|
||||||
result = fn(*args)
|
result = fn(*args)
|
||||||
elif isinstance(fn, Lambda):
|
elif isinstance(fn, Lambda):
|
||||||
result = _trampoline(_call_lambda(fn, args, env))
|
result = _trampoline(_call_lambda(fn, args, env))
|
||||||
@@ -1021,6 +1066,7 @@ _SPECIAL_FORMS: dict[str, Any] = {
|
|||||||
"define": _sf_define,
|
"define": _sf_define,
|
||||||
"defstyle": _sf_defstyle,
|
"defstyle": _sf_defstyle,
|
||||||
"defcomp": _sf_defcomp,
|
"defcomp": _sf_defcomp,
|
||||||
|
"defisland": _sf_defisland,
|
||||||
"defrelation": _sf_defrelation,
|
"defrelation": _sf_defrelation,
|
||||||
"begin": _sf_begin,
|
"begin": _sf_begin,
|
||||||
"do": _sf_begin,
|
"do": _sf_begin,
|
||||||
|
|||||||
@@ -194,9 +194,20 @@
|
|||||||
(when (= *batch-depth* 0)
|
(when (= *batch-depth* 0)
|
||||||
(let ((queue *batch-queue*))
|
(let ((queue *batch-queue*))
|
||||||
(set! *batch-queue* (list))
|
(set! *batch-queue* (list))
|
||||||
(for-each
|
;; Collect unique subscribers across all queued signals,
|
||||||
(fn (s) (flush-subscribers s))
|
;; then notify each exactly once.
|
||||||
queue)))))
|
(let ((seen (list))
|
||||||
|
(pending (list)))
|
||||||
|
(for-each
|
||||||
|
(fn (s)
|
||||||
|
(for-each
|
||||||
|
(fn (sub)
|
||||||
|
(when (not (contains? seen sub))
|
||||||
|
(append! seen sub)
|
||||||
|
(append! pending sub)))
|
||||||
|
(signal-subscribers s)))
|
||||||
|
queue)
|
||||||
|
(for-each (fn (sub) (sub)) pending))))))
|
||||||
|
|
||||||
|
|
||||||
;; --------------------------------------------------------------------------
|
;; --------------------------------------------------------------------------
|
||||||
|
|||||||
173
shared/sx/ref/test-signals.sx
Normal file
173
shared/sx/ref/test-signals.sx
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
;; ==========================================================================
|
||||||
|
;; test-signals.sx — Tests for signals and reactive islands
|
||||||
|
;;
|
||||||
|
;; Requires: test-framework.sx loaded first.
|
||||||
|
;; Modules tested: signals.sx, eval.sx (defisland)
|
||||||
|
;;
|
||||||
|
;; Note: Multi-expression lambda bodies are wrapped in (do ...) for
|
||||||
|
;; compatibility with the hand-written evaluator which only supports
|
||||||
|
;; single-expression lambda bodies.
|
||||||
|
;; ==========================================================================
|
||||||
|
|
||||||
|
|
||||||
|
;; --------------------------------------------------------------------------
|
||||||
|
;; Signal creation and basic read/write
|
||||||
|
;; --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defsuite "signal basics"
|
||||||
|
(deftest "signal creates a reactive container"
|
||||||
|
(let ((s (signal 42)))
|
||||||
|
(assert-true (signal? s))
|
||||||
|
(assert-equal 42 (deref s))))
|
||||||
|
|
||||||
|
(deftest "deref on non-signal passes through"
|
||||||
|
(assert-equal 5 (deref 5))
|
||||||
|
(assert-equal "hello" (deref "hello"))
|
||||||
|
(assert-nil (deref nil)))
|
||||||
|
|
||||||
|
(deftest "reset! changes value"
|
||||||
|
(let ((s (signal 0)))
|
||||||
|
(reset! s 10)
|
||||||
|
(assert-equal 10 (deref s))))
|
||||||
|
|
||||||
|
(deftest "reset! does not notify when value unchanged"
|
||||||
|
(let ((s (signal 5))
|
||||||
|
(count (signal 0)))
|
||||||
|
(effect (fn () (do (deref s) (swap! count inc))))
|
||||||
|
;; Effect runs once on creation → count=1
|
||||||
|
(let ((c1 (deref count)))
|
||||||
|
(reset! s 5) ;; same value — no notification
|
||||||
|
(assert-equal c1 (deref count)))))
|
||||||
|
|
||||||
|
(deftest "swap! applies function to current value"
|
||||||
|
(let ((s (signal 10)))
|
||||||
|
(swap! s inc)
|
||||||
|
(assert-equal 11 (deref s))))
|
||||||
|
|
||||||
|
(deftest "swap! passes extra args"
|
||||||
|
(let ((s (signal 10)))
|
||||||
|
(swap! s + 5)
|
||||||
|
(assert-equal 15 (deref s)))))
|
||||||
|
|
||||||
|
|
||||||
|
;; --------------------------------------------------------------------------
|
||||||
|
;; Computed signals
|
||||||
|
;; --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defsuite "computed"
|
||||||
|
(deftest "computed derives initial value"
|
||||||
|
(let ((a (signal 3))
|
||||||
|
(b (signal 4))
|
||||||
|
(sum (computed (fn () (+ (deref a) (deref b))))))
|
||||||
|
(assert-equal 7 (deref sum))))
|
||||||
|
|
||||||
|
(deftest "computed updates when dependency changes"
|
||||||
|
(let ((a (signal 2))
|
||||||
|
(doubled (computed (fn () (* 2 (deref a))))))
|
||||||
|
(assert-equal 4 (deref doubled))
|
||||||
|
(reset! a 5)
|
||||||
|
(assert-equal 10 (deref doubled))))
|
||||||
|
|
||||||
|
(deftest "computed chains"
|
||||||
|
(let ((base (signal 1))
|
||||||
|
(doubled (computed (fn () (* 2 (deref base)))))
|
||||||
|
(quadrupled (computed (fn () (* 2 (deref doubled))))))
|
||||||
|
(assert-equal 4 (deref quadrupled))
|
||||||
|
(reset! base 3)
|
||||||
|
(assert-equal 12 (deref quadrupled)))))
|
||||||
|
|
||||||
|
|
||||||
|
;; --------------------------------------------------------------------------
|
||||||
|
;; Effects
|
||||||
|
;; --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defsuite "effects"
|
||||||
|
(deftest "effect runs immediately"
|
||||||
|
(let ((ran (signal false)))
|
||||||
|
(effect (fn () (reset! ran true)))
|
||||||
|
(assert-true (deref ran))))
|
||||||
|
|
||||||
|
(deftest "effect re-runs when dependency changes"
|
||||||
|
(let ((source (signal "a"))
|
||||||
|
(log (signal (list))))
|
||||||
|
(effect (fn ()
|
||||||
|
(swap! log (fn (l) (append l (deref source))))))
|
||||||
|
;; Initial run logs "a"
|
||||||
|
(assert-equal (list "a") (deref log))
|
||||||
|
;; Change triggers re-run
|
||||||
|
(reset! source "b")
|
||||||
|
(assert-equal (list "a" "b") (deref log))))
|
||||||
|
|
||||||
|
(deftest "effect dispose stops tracking"
|
||||||
|
(let ((source (signal 0))
|
||||||
|
(count (signal 0)))
|
||||||
|
(let ((dispose (effect (fn () (do
|
||||||
|
(deref source)
|
||||||
|
(swap! count inc))))))
|
||||||
|
;; Effect ran once
|
||||||
|
(assert-equal 1 (deref count))
|
||||||
|
;; Trigger
|
||||||
|
(reset! source 1)
|
||||||
|
(assert-equal 2 (deref count))
|
||||||
|
;; Dispose
|
||||||
|
(dispose)
|
||||||
|
;; Should NOT trigger
|
||||||
|
(reset! source 2)
|
||||||
|
(assert-equal 2 (deref count)))))
|
||||||
|
|
||||||
|
(deftest "effect cleanup runs before re-run"
|
||||||
|
(let ((source (signal 0))
|
||||||
|
(cleanups (signal 0)))
|
||||||
|
(effect (fn () (do
|
||||||
|
(deref source)
|
||||||
|
(fn () (swap! cleanups inc))))) ;; return cleanup fn
|
||||||
|
;; No cleanup yet (first run)
|
||||||
|
(assert-equal 0 (deref cleanups))
|
||||||
|
;; Change triggers cleanup of previous run
|
||||||
|
(reset! source 1)
|
||||||
|
(assert-equal 1 (deref cleanups)))))
|
||||||
|
|
||||||
|
|
||||||
|
;; --------------------------------------------------------------------------
|
||||||
|
;; Batch
|
||||||
|
;; --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defsuite "batch"
|
||||||
|
(deftest "batch defers notifications"
|
||||||
|
(let ((a (signal 0))
|
||||||
|
(b (signal 0))
|
||||||
|
(run-count (signal 0)))
|
||||||
|
(effect (fn () (do
|
||||||
|
(deref a) (deref b)
|
||||||
|
(swap! run-count inc))))
|
||||||
|
;; Initial run
|
||||||
|
(assert-equal 1 (deref run-count))
|
||||||
|
;; Without batch: 2 writes → 2 effect runs
|
||||||
|
;; With batch: 2 writes → 1 effect run
|
||||||
|
(batch (fn () (do
|
||||||
|
(reset! a 1)
|
||||||
|
(reset! b 2))))
|
||||||
|
;; Should be 2 (initial + 1 batched), not 3
|
||||||
|
(assert-equal 2 (deref run-count)))))
|
||||||
|
|
||||||
|
|
||||||
|
;; --------------------------------------------------------------------------
|
||||||
|
;; defisland
|
||||||
|
;; --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
(defsuite "defisland"
|
||||||
|
(deftest "defisland creates an island"
|
||||||
|
(defisland ~test-island (&key value)
|
||||||
|
(list "island" value))
|
||||||
|
(assert-true (island? ~test-island)))
|
||||||
|
|
||||||
|
(deftest "island is callable like component"
|
||||||
|
(defisland ~greeting (&key name)
|
||||||
|
(str "Hello, " name "!"))
|
||||||
|
(assert-equal "Hello, World!" (~greeting :name "World")))
|
||||||
|
|
||||||
|
(deftest "island accepts children"
|
||||||
|
(defisland ~wrapper (&rest children)
|
||||||
|
(list "wrap" children))
|
||||||
|
(assert-equal (list "wrap" (list "a" "b"))
|
||||||
|
(~wrapper "a" "b"))))
|
||||||
@@ -21,7 +21,7 @@ sys.path.insert(0, _PROJECT)
|
|||||||
|
|
||||||
from shared.sx.parser import parse_all
|
from shared.sx.parser import parse_all
|
||||||
from shared.sx.evaluator import _eval, _trampoline, _call_lambda
|
from shared.sx.evaluator import _eval, _trampoline, _call_lambda
|
||||||
from shared.sx.types import Symbol, Keyword, Lambda, NIL
|
from shared.sx.types import Symbol, Keyword, Lambda, NIL, Component, Island
|
||||||
|
|
||||||
# --- Test state ---
|
# --- Test state ---
|
||||||
suite_stack: list[str] = []
|
suite_stack: list[str] = []
|
||||||
@@ -134,15 +134,133 @@ def render_html(sx_source):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
# --- Signal platform primitives ---
|
||||||
|
# Implements the signal runtime platform interface for testing signals.sx
|
||||||
|
|
||||||
|
class Signal:
|
||||||
|
"""A reactive signal container."""
|
||||||
|
__slots__ = ("value", "subscribers", "deps")
|
||||||
|
|
||||||
|
def __init__(self, value):
|
||||||
|
self.value = value
|
||||||
|
self.subscribers = [] # list of callables
|
||||||
|
self.deps = [] # list of Signal (for computed)
|
||||||
|
|
||||||
|
|
||||||
|
class TrackingContext:
|
||||||
|
"""Tracks signal dependencies during effect/computed evaluation."""
|
||||||
|
__slots__ = ("notify_fn", "deps")
|
||||||
|
|
||||||
|
def __init__(self, notify_fn):
|
||||||
|
self.notify_fn = notify_fn
|
||||||
|
self.deps = []
|
||||||
|
|
||||||
|
|
||||||
|
_tracking_context = [None] # mutable cell
|
||||||
|
|
||||||
|
|
||||||
|
def _make_signal(value):
|
||||||
|
s = Signal(value)
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
def _signal_p(x):
|
||||||
|
return isinstance(x, Signal)
|
||||||
|
|
||||||
|
|
||||||
|
def _signal_value(s):
|
||||||
|
return s.value
|
||||||
|
|
||||||
|
|
||||||
|
def _signal_set_value(s, v):
|
||||||
|
s.value = v
|
||||||
|
return NIL
|
||||||
|
|
||||||
|
|
||||||
|
def _signal_subscribers(s):
|
||||||
|
return list(s.subscribers)
|
||||||
|
|
||||||
|
|
||||||
|
def _signal_add_sub(s, fn):
|
||||||
|
if fn not in s.subscribers:
|
||||||
|
s.subscribers.append(fn)
|
||||||
|
return NIL
|
||||||
|
|
||||||
|
|
||||||
|
def _signal_remove_sub(s, fn):
|
||||||
|
if fn in s.subscribers:
|
||||||
|
s.subscribers.remove(fn)
|
||||||
|
return NIL
|
||||||
|
|
||||||
|
|
||||||
|
def _signal_deps(s):
|
||||||
|
return list(s.deps)
|
||||||
|
|
||||||
|
|
||||||
|
def _signal_set_deps(s, deps):
|
||||||
|
s.deps = list(deps)
|
||||||
|
return NIL
|
||||||
|
|
||||||
|
|
||||||
|
def _set_tracking_context(ctx):
|
||||||
|
_tracking_context[0] = ctx
|
||||||
|
return NIL
|
||||||
|
|
||||||
|
|
||||||
|
def _get_tracking_context():
|
||||||
|
return _tracking_context[0] or NIL
|
||||||
|
|
||||||
|
|
||||||
|
def _make_tracking_context(notify_fn):
|
||||||
|
return TrackingContext(notify_fn)
|
||||||
|
|
||||||
|
|
||||||
|
def _tracking_context_deps(ctx):
|
||||||
|
if isinstance(ctx, TrackingContext):
|
||||||
|
return ctx.deps
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _tracking_context_add_dep(ctx, s):
|
||||||
|
if isinstance(ctx, TrackingContext) and s not in ctx.deps:
|
||||||
|
ctx.deps.append(s)
|
||||||
|
return NIL
|
||||||
|
|
||||||
|
|
||||||
|
def _tracking_context_notify_fn(ctx):
|
||||||
|
if isinstance(ctx, TrackingContext):
|
||||||
|
return ctx.notify_fn
|
||||||
|
return NIL
|
||||||
|
|
||||||
|
|
||||||
|
def _identical(a, b):
|
||||||
|
return a is b
|
||||||
|
|
||||||
|
|
||||||
|
def _island_p(x):
|
||||||
|
return isinstance(x, Island)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_island(name, params, has_children, body, closure):
|
||||||
|
return Island(
|
||||||
|
name=name,
|
||||||
|
params=list(params),
|
||||||
|
has_children=has_children,
|
||||||
|
body=body,
|
||||||
|
closure=dict(closure) if isinstance(closure, dict) else {},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# --- Spec registry ---
|
# --- Spec registry ---
|
||||||
|
|
||||||
SPECS = {
|
SPECS = {
|
||||||
"eval": {"file": "test-eval.sx", "needs": []},
|
"eval": {"file": "test-eval.sx", "needs": []},
|
||||||
"parser": {"file": "test-parser.sx", "needs": ["sx-parse"]},
|
"parser": {"file": "test-parser.sx", "needs": ["sx-parse"]},
|
||||||
"router": {"file": "test-router.sx", "needs": []},
|
"router": {"file": "test-router.sx", "needs": []},
|
||||||
"render": {"file": "test-render.sx", "needs": ["render-html"]},
|
"render": {"file": "test-render.sx", "needs": ["render-html"]},
|
||||||
"deps": {"file": "test-deps.sx", "needs": []},
|
"deps": {"file": "test-deps.sx", "needs": []},
|
||||||
"engine": {"file": "test-engine.sx", "needs": []},
|
"engine": {"file": "test-engine.sx", "needs": []},
|
||||||
|
"signals": {"file": "test-signals.sx", "needs": ["make-signal"]},
|
||||||
}
|
}
|
||||||
|
|
||||||
REF_DIR = os.path.join(_HERE, "..", "ref")
|
REF_DIR = os.path.join(_HERE, "..", "ref")
|
||||||
@@ -186,6 +304,31 @@ env = {
|
|||||||
"inc": lambda n: n + 1,
|
"inc": lambda n: n + 1,
|
||||||
# Component accessor for affinity (Phase 7)
|
# Component accessor for affinity (Phase 7)
|
||||||
"component-affinity": lambda c: getattr(c, 'affinity', 'auto'),
|
"component-affinity": lambda c: getattr(c, 'affinity', 'auto'),
|
||||||
|
# Signal platform primitives
|
||||||
|
"make-signal": _make_signal,
|
||||||
|
"signal?": _signal_p,
|
||||||
|
"signal-value": _signal_value,
|
||||||
|
"signal-set-value!": _signal_set_value,
|
||||||
|
"signal-subscribers": _signal_subscribers,
|
||||||
|
"signal-add-sub!": _signal_add_sub,
|
||||||
|
"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?": _identical,
|
||||||
|
# Island platform primitives
|
||||||
|
"island?": _island_p,
|
||||||
|
"make-island": _make_island,
|
||||||
|
"component-name": lambda c: getattr(c, 'name', ''),
|
||||||
|
"component-params": lambda c: list(getattr(c, 'params', [])),
|
||||||
|
"component-body": lambda c: getattr(c, 'body', NIL),
|
||||||
|
"component-closure": lambda c: dict(getattr(c, 'closure', {})),
|
||||||
|
"component-has-children?": lambda c: getattr(c, 'has_children', False),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -352,6 +495,171 @@ def _load_forms_from_bootstrap(env):
|
|||||||
eval_file("forms.sx", env)
|
eval_file("forms.sx", env)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_signals(env):
|
||||||
|
"""Load signals.sx spec — defines signal, deref, reset!, swap!, etc.
|
||||||
|
|
||||||
|
The hand-written evaluator doesn't support &rest in define/fn,
|
||||||
|
so we override swap! with a native implementation after loading.
|
||||||
|
"""
|
||||||
|
# callable? is needed by effect (to check if return value is cleanup fn)
|
||||||
|
env["callable?"] = lambda x: callable(x) or isinstance(x, Lambda)
|
||||||
|
eval_file("signals.sx", env)
|
||||||
|
|
||||||
|
# Override signal functions that need to call Lambda subscribers.
|
||||||
|
# The hand-written evaluator's Lambda objects can't be called directly
|
||||||
|
# from Python — they need _call_lambda. So we provide native versions
|
||||||
|
# of functions that bridge native→Lambda calls.
|
||||||
|
|
||||||
|
def _call_sx_fn(fn, args):
|
||||||
|
"""Call an SX function (Lambda or native) from Python."""
|
||||||
|
if isinstance(fn, Lambda):
|
||||||
|
return _trampoline(_call_lambda(fn, list(args), env))
|
||||||
|
if callable(fn):
|
||||||
|
return fn(*args)
|
||||||
|
return NIL
|
||||||
|
|
||||||
|
def _flush_subscribers(s):
|
||||||
|
for sub in list(s.subscribers):
|
||||||
|
_call_sx_fn(sub, [])
|
||||||
|
return NIL
|
||||||
|
|
||||||
|
def _notify_subscribers(s):
|
||||||
|
batch_depth = env.get("*batch-depth*", 0)
|
||||||
|
if batch_depth and batch_depth > 0:
|
||||||
|
batch_queue = env.get("*batch-queue*", [])
|
||||||
|
if s not in batch_queue:
|
||||||
|
batch_queue.append(s)
|
||||||
|
return NIL
|
||||||
|
_flush_subscribers(s)
|
||||||
|
return NIL
|
||||||
|
env["notify-subscribers"] = _notify_subscribers
|
||||||
|
env["flush-subscribers"] = _flush_subscribers
|
||||||
|
|
||||||
|
def _reset_bang(s, value):
|
||||||
|
if not isinstance(s, Signal):
|
||||||
|
return NIL
|
||||||
|
old = s.value
|
||||||
|
if old is not value:
|
||||||
|
s.value = value
|
||||||
|
_notify_subscribers(s)
|
||||||
|
return NIL
|
||||||
|
env["reset!"] = _reset_bang
|
||||||
|
|
||||||
|
def _swap_bang(s, f, *args):
|
||||||
|
if not isinstance(s, Signal):
|
||||||
|
return NIL
|
||||||
|
old = s.value
|
||||||
|
all_args = [old] + list(args)
|
||||||
|
new_val = _call_sx_fn(f, all_args)
|
||||||
|
if old is not new_val:
|
||||||
|
s.value = new_val
|
||||||
|
_notify_subscribers(s)
|
||||||
|
return NIL
|
||||||
|
env["swap!"] = _swap_bang
|
||||||
|
|
||||||
|
def _computed(compute_fn):
|
||||||
|
s = Signal(NIL)
|
||||||
|
|
||||||
|
def recompute():
|
||||||
|
# Unsubscribe from old deps
|
||||||
|
for dep in s.deps:
|
||||||
|
if recompute in dep.subscribers:
|
||||||
|
dep.subscribers.remove(recompute)
|
||||||
|
s.deps = []
|
||||||
|
|
||||||
|
# Create tracking context
|
||||||
|
ctx = TrackingContext(recompute)
|
||||||
|
prev = _tracking_context[0]
|
||||||
|
_tracking_context[0] = ctx
|
||||||
|
|
||||||
|
new_val = _call_sx_fn(compute_fn, [])
|
||||||
|
|
||||||
|
_tracking_context[0] = prev
|
||||||
|
s.deps = list(ctx.deps)
|
||||||
|
|
||||||
|
old = s.value
|
||||||
|
s.value = new_val
|
||||||
|
if old is not new_val:
|
||||||
|
_flush_subscribers(s)
|
||||||
|
|
||||||
|
recompute()
|
||||||
|
return s
|
||||||
|
env["computed"] = _computed
|
||||||
|
|
||||||
|
def _effect(effect_fn):
|
||||||
|
deps = []
|
||||||
|
disposed = [False]
|
||||||
|
cleanup_fn = [None]
|
||||||
|
|
||||||
|
def run_effect():
|
||||||
|
if disposed[0]:
|
||||||
|
return NIL
|
||||||
|
# Run previous cleanup
|
||||||
|
if cleanup_fn[0]:
|
||||||
|
_call_sx_fn(cleanup_fn[0], [])
|
||||||
|
cleanup_fn[0] = None
|
||||||
|
|
||||||
|
# Unsubscribe from old deps
|
||||||
|
for dep in deps:
|
||||||
|
if run_effect in dep.subscribers:
|
||||||
|
dep.subscribers.remove(run_effect)
|
||||||
|
deps.clear()
|
||||||
|
|
||||||
|
# Track new deps
|
||||||
|
ctx = TrackingContext(run_effect)
|
||||||
|
prev = _tracking_context[0]
|
||||||
|
_tracking_context[0] = ctx
|
||||||
|
|
||||||
|
result = _call_sx_fn(effect_fn, [])
|
||||||
|
|
||||||
|
_tracking_context[0] = prev
|
||||||
|
deps.extend(ctx.deps)
|
||||||
|
|
||||||
|
# If effect returns a callable, it's cleanup
|
||||||
|
if callable(result) or isinstance(result, Lambda):
|
||||||
|
cleanup_fn[0] = result
|
||||||
|
|
||||||
|
return NIL
|
||||||
|
|
||||||
|
run_effect()
|
||||||
|
|
||||||
|
def dispose():
|
||||||
|
disposed[0] = True
|
||||||
|
if cleanup_fn[0]:
|
||||||
|
_call_sx_fn(cleanup_fn[0], [])
|
||||||
|
for dep in deps:
|
||||||
|
if run_effect in dep.subscribers:
|
||||||
|
dep.subscribers.remove(run_effect)
|
||||||
|
deps.clear()
|
||||||
|
return NIL
|
||||||
|
|
||||||
|
return dispose
|
||||||
|
env["effect"] = _effect
|
||||||
|
|
||||||
|
def _batch(thunk):
|
||||||
|
depth = env.get("*batch-depth*", 0)
|
||||||
|
env["*batch-depth*"] = depth + 1
|
||||||
|
_call_sx_fn(thunk, [])
|
||||||
|
env["*batch-depth*"] = env["*batch-depth*"] - 1
|
||||||
|
if env["*batch-depth*"] == 0:
|
||||||
|
queue = env.get("*batch-queue*", [])
|
||||||
|
env["*batch-queue*"] = []
|
||||||
|
# Collect unique subscribers across all queued signals
|
||||||
|
seen = set()
|
||||||
|
pending = []
|
||||||
|
for s in queue:
|
||||||
|
for sub in s.subscribers:
|
||||||
|
sub_id = id(sub)
|
||||||
|
if sub_id not in seen:
|
||||||
|
seen.add(sub_id)
|
||||||
|
pending.append(sub)
|
||||||
|
# Notify each unique subscriber exactly once
|
||||||
|
for sub in pending:
|
||||||
|
_call_sx_fn(sub, [])
|
||||||
|
return NIL
|
||||||
|
env["batch"] = _batch
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
global passed, failed, test_num
|
global passed, failed, test_num
|
||||||
|
|
||||||
@@ -395,6 +703,8 @@ def main():
|
|||||||
_load_deps_from_bootstrap(env)
|
_load_deps_from_bootstrap(env)
|
||||||
if spec_name == "engine":
|
if spec_name == "engine":
|
||||||
_load_engine_from_bootstrap(env)
|
_load_engine_from_bootstrap(env)
|
||||||
|
if spec_name == "signals":
|
||||||
|
_load_signals(env)
|
||||||
|
|
||||||
print(f"# --- {spec_name} ---")
|
print(f"# --- {spec_name} ---")
|
||||||
eval_file(spec["file"], env)
|
eval_file(spec["file"], env)
|
||||||
|
|||||||
@@ -189,6 +189,31 @@ class Component:
|
|||||||
return f"<Component ~{self.name}({', '.join(self.params)})>"
|
return f"<Component ~{self.name}({', '.join(self.params)})>"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Island
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Island:
|
||||||
|
"""A reactive UI component defined via ``(defisland ~name (&key ...) body)``.
|
||||||
|
|
||||||
|
Islands are like components but create a reactive boundary. Inside an
|
||||||
|
island, signals are tracked — deref subscribes DOM nodes to signals.
|
||||||
|
On the server, islands render as static HTML with hydration attributes.
|
||||||
|
"""
|
||||||
|
name: str
|
||||||
|
params: list[str]
|
||||||
|
has_children: bool
|
||||||
|
body: Any
|
||||||
|
closure: dict[str, Any] = field(default_factory=dict)
|
||||||
|
css_classes: set[str] = field(default_factory=set)
|
||||||
|
deps: set[str] = field(default_factory=set)
|
||||||
|
io_refs: set[str] = field(default_factory=set)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Island ~{self.name}({', '.join(self.params)})>"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# HandlerDef
|
# HandlerDef
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -355,4 +380,4 @@ class _ShiftSignal(BaseException):
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
# An s-expression value after evaluation
|
# An s-expression value after evaluation
|
||||||
SExp = int | float | str | bool | Symbol | Keyword | Lambda | Macro | Component | Continuation | HandlerDef | RelationDef | PageDef | QueryDef | ActionDef | list | dict | _Nil | None
|
SExp = int | float | str | bool | Symbol | Keyword | Lambda | Macro | Component | Island | Continuation | HandlerDef | RelationDef | PageDef | QueryDef | ActionDef | list | dict | _Nil | None
|
||||||
|
|||||||
Reference in New Issue
Block a user