Merge branch 'worktree-react' into macros

# Conflicts:
#	shared/sx/tests/run.py
This commit is contained in:
2026-03-08 10:17:40 +00:00
17 changed files with 3759 additions and 532 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -33,7 +33,7 @@ from __future__ import annotations
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
@@ -147,13 +147,13 @@ def _eval(expr: Any, env: dict[str, Any]) -> Any:
fn = _trampoline(_eval(head, env))
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)
if isinstance(fn, Lambda):
return _call_lambda(fn, args, env)
if isinstance(fn, Component):
if isinstance(fn, (Component, Island)):
return _call_component(fn, expr[1:], env)
raise EvalError(f"Not callable: {fn!r}")
@@ -555,6 +555,51 @@ def _sf_defcomp(expr: list, env: dict) -> Component:
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:
"""Extract a keyword annotation from defcomp, e.g. :affinity :client."""
# 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:
fn = _trampoline(_eval(form, env))
args = [result]
if callable(fn) and not isinstance(fn, (Lambda, Component)):
if callable(fn) and not isinstance(fn, (Lambda, Component, Island)):
result = fn(*args)
elif isinstance(fn, Lambda):
result = _trampoline(_call_lambda(fn, args, env))
@@ -1021,6 +1066,7 @@ _SPECIAL_FORMS: dict[str, Any] = {
"define": _sf_define,
"defstyle": _sf_defstyle,
"defcomp": _sf_defcomp,
"defisland": _sf_defisland,
"defrelation": _sf_defrelation,
"begin": _sf_begin,
"do": _sf_begin,

View File

@@ -102,6 +102,12 @@
(contains? HTML_TAGS name)
(render-dom-element name args env ns)
;; Island (~name) — reactive component
(and (starts-with? name "~")
(env-has? env name)
(island? (env-get env name)))
(render-dom-island (env-get env name) args env ns)
;; Component (~name)
(starts-with? name "~")
(let ((comp (env-get env name)))
@@ -284,7 +290,7 @@
(define RENDER_DOM_FORMS
(list "if" "when" "cond" "case" "let" "let*" "begin" "do"
"define" "defcomp" "defmacro" "defstyle" "defhandler"
"define" "defcomp" "defisland" "defmacro" "defstyle" "defhandler"
"map" "map-indexed" "filter" "for-each"))
(define render-dom-form?
@@ -414,6 +420,153 @@
(render-to-dom (lambda-body f) local ns))))
;; --------------------------------------------------------------------------
;; render-dom-island — render a reactive island
;; --------------------------------------------------------------------------
;;
;; Islands render like components but wrapped in a reactive context.
;; The island container element gets data-sx-island and data-sx-state
;; attributes for identification and hydration.
;;
;; Inside the island body, deref calls create reactive DOM subscriptions:
;; - Text bindings: (deref sig) in text position → reactive text node
;; - Attribute bindings: (deref sig) in attr → reactive attribute
;; - Conditional fragments: (when (deref sig) ...) → reactive show/hide
(define render-dom-island
(fn (island args env ns)
;; Parse kwargs and children (same as component)
(let ((kwargs (dict))
(children (list)))
(reduce
(fn (state arg)
(let ((skip (get state "skip")))
(if skip
(assoc state "skip" false "i" (inc (get state "i")))
(if (and (= (type-of arg) "keyword")
(< (inc (get state "i")) (len args)))
(let ((val (trampoline
(eval-expr (nth args (inc (get state "i"))) env))))
(dict-set! kwargs (keyword-name arg) val)
(assoc state "skip" true "i" (inc (get state "i"))))
(do
(append! children arg)
(assoc state "i" (inc (get state "i"))))))))
(dict "i" 0 "skip" false)
args)
;; Build island env: closure + caller env + params
(let ((local (env-merge (component-closure island) env))
(island-name (component-name island)))
;; Bind params from kwargs
(for-each
(fn (p)
(env-set! local p (if (dict-has? kwargs p) (dict-get kwargs p) nil)))
(component-params island))
;; If island accepts children, pre-render them to a fragment
(when (component-has-children? island)
(let ((child-frag (create-fragment)))
(for-each
(fn (c) (dom-append child-frag (render-to-dom c env ns)))
children)
(env-set! local "children" child-frag)))
;; Create the island container element
(let ((container (dom-create-element "div" nil))
(disposers (list)))
;; Mark as island
(dom-set-attr container "data-sx-island" island-name)
;; Render island body inside a scope that tracks disposers
(let ((body-dom
(with-island-scope
(fn (disposable) (append! disposers disposable))
(fn () (render-to-dom (component-body island) local ns)))))
(dom-append container body-dom)
;; Store disposers on the container for cleanup
(dom-set-data container "sx-disposers" disposers)
container))))))
;; --------------------------------------------------------------------------
;; Reactive DOM rendering helpers
;; --------------------------------------------------------------------------
;;
;; These functions create reactive bindings between signals and DOM nodes.
;; They are called by the platform's renderDOM when it detects deref
;; calls inside an island context.
;; reactive-text — create a text node bound to a signal
;; Used when (deref sig) appears in a text position inside an island.
(define reactive-text
(fn (sig)
(let ((node (create-text-node (str (deref sig)))))
(effect (fn ()
(dom-set-text-content node (str (deref sig)))))
node)))
;; reactive-attr — bind an element attribute to a signal expression
;; Used when an attribute value contains (deref sig) inside an island.
(define reactive-attr
(fn (el attr-name compute-fn)
(effect (fn ()
(let ((val (compute-fn)))
(cond
(or (nil? val) (= val false))
(dom-remove-attr el attr-name)
(= val true)
(dom-set-attr el attr-name "")
:else
(dom-set-attr el attr-name (str val))))))))
;; reactive-fragment — conditionally render a fragment based on a signal
;; Used for (when (deref sig) ...) or (if (deref sig) ...) inside an island.
(define reactive-fragment
(fn (test-fn render-fn env ns)
(let ((marker (create-comment "island-fragment"))
(current-nodes (list)))
(effect (fn ()
;; Remove previous nodes
(for-each (fn (n) (dom-remove n)) current-nodes)
(set! current-nodes (list))
;; If test passes, render and insert after marker
(when (test-fn)
(let ((frag (render-fn)))
(set! current-nodes (dom-child-nodes frag))
(dom-insert-after marker frag)))))
marker)))
;; reactive-list — render a keyed list bound to a signal
;; Used for (map fn (deref items)) inside an island.
(define reactive-list
(fn (map-fn items-sig env ns)
(let ((container (create-fragment))
(marker (create-comment "island-list")))
(dom-append container marker)
(effect (fn ()
;; Simple strategy: clear and re-render
;; Future: keyed reconciliation
(let ((parent (dom-parent marker)))
(when parent
;; Remove all nodes after marker until next sibling marker
(dom-remove-children-after marker)
;; Render new items
(let ((items (deref items-sig)))
(for-each
(fn (item)
(let ((rendered (if (lambda? map-fn)
(render-lambda-dom map-fn (list item) env ns)
(render-to-dom (apply map-fn (list item)) env ns))))
(dom-insert-after marker rendered)))
(reverse items)))))))
container)))
;; --------------------------------------------------------------------------
;; Platform interface — DOM adapter
;; --------------------------------------------------------------------------
@@ -422,11 +575,20 @@
;; (dom-create-element tag ns) → Element (ns=nil for HTML, string for SVG/MathML)
;; (create-text-node s) → Text node
;; (create-fragment) → DocumentFragment
;; (create-comment s) → Comment node
;;
;; Tree mutation:
;; (dom-append parent child) → void (appendChild)
;; (dom-set-attr el name val) → void (setAttribute)
;; (dom-remove-attr el name) → void (removeAttribute)
;; (dom-get-attr el name) → string or nil (getAttribute)
;; (dom-set-text-content n s) → void (set textContent)
;; (dom-remove node) → void (remove from parent)
;; (dom-insert-after ref node) → void (insert node after ref)
;; (dom-parent node) → parent Element or nil
;; (dom-child-nodes frag) → list of child nodes
;; (dom-remove-children-after m)→ void (remove all siblings after marker)
;; (dom-set-data el key val) → void (store arbitrary data on element)
;;
;; Content parsing:
;; (dom-parse-html s) → DocumentFragment from HTML string
@@ -441,11 +603,15 @@
;; From eval.sx:
;; eval-expr, trampoline, expand-macro, process-bindings, eval-cond
;; env-has?, env-get, env-set!, env-merge
;; lambda?, component?, macro?
;; lambda?, component?, island?, macro?
;; lambda-closure, lambda-params, lambda-body
;; component-params, component-body, component-closure,
;; component-has-children?, component-name
;;
;; From signals.sx:
;; signal, deref, reset!, swap!, computed, effect, batch
;; signal?, with-island-scope
;;
;; Iteration:
;; (for-each-indexed fn coll) → call fn(index, item) for each element
;; --------------------------------------------------------------------------

View File

@@ -8,7 +8,7 @@
;; parse-element-args, render-attrs, definition-form?
;; eval.sx — eval-expr, trampoline, expand-macro, process-bindings,
;; eval-cond, env-has?, env-get, env-set!, env-merge,
;; lambda?, component?, macro?,
;; lambda?, component?, island?, macro?,
;; lambda-closure, lambda-params, lambda-body
;; ==========================================================================
@@ -50,7 +50,7 @@
(define RENDER_HTML_FORMS
(list "if" "when" "cond" "case" "let" "let*" "begin" "do"
"define" "defcomp" "defmacro" "defstyle" "defhandler"
"define" "defcomp" "defisland" "defmacro" "defstyle" "defhandler"
"map" "map-indexed" "filter" "for-each"))
(define render-html-form?
@@ -85,6 +85,12 @@
(contains? HTML_TAGS name)
(render-html-element name args env)
;; Island (~name) — reactive component, SSR with hydration markers
(and (starts-with? name "~")
(env-has? env name)
(island? (env-get env name)))
(render-html-island (env-get env name) args env)
;; Component or macro call (~name)
(starts-with? name "~")
(let ((val (env-get env name)))
@@ -287,6 +293,85 @@
"</" tag ">"))))))
;; --------------------------------------------------------------------------
;; render-html-island — SSR rendering of a reactive island
;; --------------------------------------------------------------------------
;;
;; Renders the island body as static HTML wrapped in a container element
;; with data-sx-island and data-sx-state attributes. The client hydrates
;; this by finding these elements and re-rendering with reactive context.
;;
;; On the server, signal/deref/reset!/swap! are simple passthrough:
;; (signal val) → returns val (no container needed server-side)
;; (deref s) → returns s (signal values are plain values server-side)
;; (reset! s v) → no-op
;; (swap! s f) → no-op
(define render-html-island
(fn (island args env)
;; Parse kwargs and children (same pattern as render-html-component)
(let ((kwargs (dict))
(children (list)))
(reduce
(fn (state arg)
(let ((skip (get state "skip")))
(if skip
(assoc state "skip" false "i" (inc (get state "i")))
(if (and (= (type-of arg) "keyword")
(< (inc (get state "i")) (len args)))
(let ((val (trampoline
(eval-expr (nth args (inc (get state "i"))) env))))
(dict-set! kwargs (keyword-name arg) val)
(assoc state "skip" true "i" (inc (get state "i"))))
(do
(append! children arg)
(assoc state "i" (inc (get state "i"))))))))
(dict "i" 0 "skip" false)
args)
;; Build island env: closure + caller env + params
(let ((local (env-merge (component-closure island) env))
(island-name (component-name island)))
;; Bind params from kwargs
(for-each
(fn (p)
(env-set! local p (if (dict-has? kwargs p) (dict-get kwargs p) nil)))
(component-params island))
;; If island accepts children, pre-render them to raw HTML
(when (component-has-children? island)
(env-set! local "children"
(make-raw-html
(join "" (map (fn (c) (render-to-html c env)) children)))))
;; Render the island body as HTML
(let ((body-html (render-to-html (component-body island) local))
(state-json (serialize-island-state kwargs)))
;; Wrap in container with hydration attributes
(str "<div data-sx-island=\"" (escape-attr island-name) "\""
(if state-json
(str " data-sx-state=\"" (escape-attr state-json) "\"")
"")
">"
body-html
"</div>"))))))
;; --------------------------------------------------------------------------
;; serialize-island-state — serialize kwargs to JSON for hydration
;; --------------------------------------------------------------------------
;;
;; Only serializes simple values (numbers, strings, booleans, nil, lists, dicts).
;; Functions, components, and other non-serializable values are skipped.
(define serialize-island-state
(fn (kwargs)
(if (empty-dict? kwargs)
nil
(json-serialize kwargs))))
;; --------------------------------------------------------------------------
;; Platform interface — HTML adapter
;; --------------------------------------------------------------------------
@@ -297,7 +382,7 @@
;; From eval.sx:
;; eval-expr, trampoline, expand-macro, process-bindings, eval-cond
;; env-has?, env-get, env-set!, env-merge
;; lambda?, component?, macro?
;; lambda?, component?, island?, macro?
;; lambda-closure, lambda-params, lambda-body
;; component-params, component-body, component-closure,
;; component-has-children?, component-name
@@ -305,6 +390,11 @@
;; Raw HTML construction:
;; (make-raw-html s) → wrap string as raw HTML (not double-escaped)
;;
;; JSON serialization (for island state):
;; (json-serialize dict) → JSON string
;; (empty-dict? d) → boolean
;; (escape-attr s) → HTML attribute escape
;;
;; Iteration:
;; (for-each-indexed fn coll) → call fn(index, item) for each element
;; (map-indexed fn coll) → map fn(index, item) over each element

View File

@@ -83,12 +83,14 @@
(let ((f (trampoline (eval-expr head env)))
(evaled-args (map (fn (a) (trampoline (eval-expr a env))) args)))
(cond
(and (callable? f) (not (lambda? f)) (not (component? f)))
(and (callable? f) (not (lambda? f)) (not (component? f)) (not (island? f)))
(apply f evaled-args)
(lambda? f)
(trampoline (call-lambda f evaled-args env))
(component? f)
(aser-call (str "~" (component-name f)) args env)
(island? f)
(aser-call (str "~" (component-name f)) args env)
:else (error (str "Not callable: " (inspect f)))))))))))

View File

@@ -139,6 +139,32 @@ class JSEmitter:
"callable?": "isCallable",
"lambda?": "isLambda",
"component?": "isComponent",
"island?": "isIsland",
"make-island": "makeIsland",
"make-signal": "makeSignal",
"signal?": "isSignal",
"signal-value": "signalValue",
"signal-set-value!": "signalSetValue",
"signal-subscribers": "signalSubscribers",
"signal-add-sub!": "signalAddSub",
"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",
"dispose-computed": "disposeComputed",
"with-island-scope": "withIslandScope",
"register-in-scope": "registerInScope",
"*batch-depth*": "_batchDepth",
"*batch-queue*": "_batchQueue",
"*island-scope*": "_islandScope",
"macro?": "isMacro",
"primitive?": "isPrimitive",
"get-primitive": "getPrimitive",
@@ -166,6 +192,10 @@ class JSEmitter:
"render-list-to-html": "renderListToHtml",
"render-html-element": "renderHtmlElement",
"render-html-component": "renderHtmlComponent",
"render-html-island": "renderHtmlIsland",
"serialize-island-state": "serializeIslandState",
"json-serialize": "jsonSerialize",
"empty-dict?": "isEmptyDict",
"parse-element-args": "parseElementArgs",
"render-attrs": "renderAttrs",
"aser-list": "aserList",
@@ -191,6 +221,7 @@ class JSEmitter:
"sf-lambda": "sfLambda",
"sf-define": "sfDefine",
"sf-defcomp": "sfDefcomp",
"sf-defisland": "sfDefisland",
"defcomp-kwarg": "defcompKwarg",
"sf-defmacro": "sfDefmacro",
"sf-begin": "sfBegin",
@@ -240,6 +271,11 @@ class JSEmitter:
"render-dom-form?": "isRenderDomForm",
"dispatch-render-form": "dispatchRenderForm",
"render-lambda-dom": "renderLambdaDom",
"render-dom-island": "renderDomIsland",
"reactive-text": "reactiveText",
"reactive-attr": "reactiveAttr",
"reactive-fragment": "reactiveFragment",
"reactive-list": "reactiveList",
"dom-create-element": "domCreateElement",
"dom-append": "domAppend",
"dom-set-attr": "domSetAttr",
@@ -281,6 +317,11 @@ class JSEmitter:
"dom-query": "domQuery",
"dom-query-all": "domQueryAll",
"dom-tag-name": "domTagName",
"create-comment": "createComment",
"dom-remove": "domRemove",
"dom-child-nodes": "domChildNodes",
"dom-remove-children-after": "domRemoveChildrenAfter",
"dom-set-data": "domSetData",
"dict-has?": "dictHas",
"dict-delete!": "dictDelete",
"process-bindings": "processBindings",
@@ -605,17 +646,39 @@ class JSEmitter:
params = expr[1]
body = expr[2:]
param_names = []
for p in params:
rest_name = None
i = 0
while i < len(params):
p = params[i]
if isinstance(p, Symbol) and p.name == "&rest":
# Next param is the rest parameter
if i + 1 < len(params):
rest_name = self._mangle(params[i + 1].name if isinstance(params[i + 1], Symbol) else str(params[i + 1]))
i += 2
continue
else:
i += 1
continue
if isinstance(p, Symbol):
param_names.append(self._mangle(p.name))
else:
param_names.append(str(p))
i += 1
params_str = ", ".join(param_names)
# Build rest-param preamble if needed
rest_preamble = ""
if rest_name:
n = len(param_names)
rest_preamble = f"var {rest_name} = Array.prototype.slice.call(arguments, {n}); "
if len(body) == 1:
body_js = self.emit(body[0])
if rest_preamble:
return f"function({params_str}) {{ {rest_preamble}return {body_js}; }}"
return f"function({params_str}) {{ return {body_js}; }}"
# Multi-expression body: statements then return last
parts = []
if rest_preamble:
parts.append(rest_preamble.rstrip())
for b in body[:-1]:
parts.append(self.emit_statement(b))
parts.append(f"return {self.emit(body[-1])};")
@@ -1045,6 +1108,7 @@ ADAPTER_DEPS = {
SPEC_MODULES = {
"deps": ("deps.sx", "deps (component dependency analysis)"),
"router": ("router.sx", "router (client-side route matching)"),
"signals": ("signals.sx", "signals (reactive signal runtime)"),
}
@@ -1833,6 +1897,9 @@ def compile_ref_to_js(
if sm not in SPEC_MODULES:
raise ValueError(f"Unknown spec module: {sm!r}. Valid: {', '.join(SPEC_MODULES)}")
spec_mod_set.add(sm)
# dom adapter uses signal runtime for reactive islands
if "dom" in adapter_set and "signals" in SPEC_MODULES:
spec_mod_set.add("signals")
# boot.sx uses parse-route-pattern from router.sx
if "boot" in adapter_set:
spec_mod_set.add("router")
@@ -1877,6 +1944,7 @@ def compile_ref_to_js(
has_orch = "orchestration" in adapter_set
has_boot = "boot" in adapter_set
has_parser = "parser" in adapter_set
has_signals = "signals" in spec_mod_set
adapter_label = "+".join(sorted(adapter_set)) if adapter_set else "core-only"
# Determine which primitive modules to include
@@ -1925,7 +1993,7 @@ def compile_ref_to_js(
parts.append(CONTINUATIONS_JS)
if has_dom:
parts.append(ASYNC_IO_JS)
parts.append(public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_boot, has_parser, adapter_label, has_deps, has_router))
parts.append(public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_boot, has_parser, adapter_label, has_deps, has_router, has_signals))
parts.append(EPILOGUE)
from datetime import datetime, timezone
build_ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
@@ -1984,6 +2052,29 @@ PREAMBLE = '''\
}
Component.prototype._component = true;
function Island(name, params, hasChildren, body, closure) {
this.name = name;
this.params = params;
this.hasChildren = hasChildren;
this.body = body;
this.closure = closure || {};
}
Island.prototype._island = true;
function SxSignal(value) {
this.value = value;
this.subscribers = [];
this.deps = [];
}
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;
@@ -2240,6 +2331,8 @@ PLATFORM_JS_PRE = '''
if (x._thunk) return "thunk";
if (x._lambda) return "lambda";
if (x._component) return "component";
if (x._island) return "island";
if (x._signal) return "signal";
if (x._macro) return "macro";
if (x._raw) return "raw-html";
if (typeof Node !== "undefined" && x instanceof Node) return "dom-node";
@@ -2287,7 +2380,41 @@ PLATFORM_JS_PRE = '''
function isCallable(x) { return typeof x === "function" || (x != null && x._lambda === true); }
function isLambda(x) { return x != null && x._lambda === true; }
function isComponent(x) { return x != null && x._component === true; }
function isIsland(x) { return x != null && x._island === true; }
function isMacro(x) { return x != null && x._macro === true; }
function isIdentical(a, b) { return a === b; }
// Island platform
function makeIsland(name, params, hasChildren, body, env) {
return new Island(name, params, hasChildren, body, merge(env));
}
// Signal platform
function makeSignal(value) { return new SxSignal(value); }
function isSignal(x) { return x != null && x._signal === true; }
function signalValue(s) { return s.value; }
function signalSetValue(s, v) { s.value = v; }
function signalSubscribers(s) { return s.subscribers.slice(); }
function signalAddSub(s, fn) { if (s.subscribers.indexOf(fn) < 0) s.subscribers.push(fn); }
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; }
// JSON / dict helpers for island state serialization
function jsonSerialize(obj) {
try { return JSON.stringify(obj); } catch(e) { return "{}"; }
}
function isEmptyDict(d) {
if (!d || typeof d !== "object") return true;
for (var k in d) if (d.hasOwnProperty(k)) return false;
return true;
}
function envHas(env, name) { return name in env; }
function envGet(env, name) { return env[name]; }
@@ -2616,6 +2743,10 @@ PLATFORM_DOM_JS = """
return _hasDom ? document.createTextNode(s) : null;
}
function createComment(s) {
return _hasDom ? document.createComment(s || "") : null;
}
function createFragment() {
return _hasDom ? document.createDocumentFragment() : null;
}
@@ -2750,6 +2881,23 @@ PLATFORM_DOM_JS = """
function domTagName(el) { return el && el.tagName ? el.tagName : ""; }
// Island DOM helpers
function domRemove(node) {
if (node && node.parentNode) node.parentNode.removeChild(node);
}
function domChildNodes(el) {
if (!el || !el.childNodes) return [];
return Array.prototype.slice.call(el.childNodes);
}
function domRemoveChildrenAfter(marker) {
if (!marker || !marker.parentNode) return;
var parent = marker.parentNode;
while (marker.nextSibling) parent.removeChild(marker.nextSibling);
}
function domSetData(el, key, val) {
if (el) { if (!el._sxData) el._sxData = {}; el._sxData[key] = val; }
}
// =========================================================================
// Performance overrides — replace transpiled spec with imperative JS
// =========================================================================
@@ -3868,7 +4016,7 @@ def fixups_js(has_html, has_sx, has_dom):
return "\n".join(lines)
def public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_boot, has_parser, adapter_label, has_deps=False, has_router=False):
def public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_boot, has_parser, adapter_label, has_deps=False, has_router=False, has_signals=False):
# Parser: use compiled sxParse from parser.sx, or inline a minimal fallback
if has_parser:
parser = '''
@@ -4020,6 +4168,16 @@ def public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_boot, has
api_lines.append(' registerIoDeps: typeof registerIoDeps === "function" ? registerIoDeps : null,')
api_lines.append(' asyncRender: typeof asyncSxRenderWithEnv === "function" ? asyncSxRenderWithEnv : null,')
api_lines.append(' asyncRenderToDom: typeof asyncRenderToDom === "function" ? asyncRenderToDom : null,')
if has_signals:
api_lines.append(' signal: signal,')
api_lines.append(' deref: deref,')
api_lines.append(' reset: reset_b,')
api_lines.append(' swap: swap_b,')
api_lines.append(' computed: computed,')
api_lines.append(' effect: effect,')
api_lines.append(' batch: batch,')
api_lines.append(' isSignal: isSignal,')
api_lines.append(' makeSignal: makeSignal,')
api_lines.append(f' _version: "{version}"')
api_lines.append(' };')
api_lines.append('')

View File

@@ -148,6 +148,32 @@ class PyEmitter:
"callable?": "is_callable",
"lambda?": "is_lambda",
"component?": "is_component",
"island?": "is_island",
"make-island": "make_island",
"make-signal": "make_signal",
"signal?": "is_signal",
"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?": "is_identical",
"notify-subscribers": "notify_subscribers",
"flush-subscribers": "flush_subscribers",
"dispose-computed": "dispose_computed",
"with-island-scope": "with_island_scope",
"register-in-scope": "register_in_scope",
"*batch-depth*": "_batch_depth",
"*batch-queue*": "_batch_queue",
"*island-scope*": "_island_scope",
"macro?": "is_macro",
"primitive?": "is_primitive",
"get-primitive": "get_primitive",
@@ -232,6 +258,11 @@ class PyEmitter:
"dispatch-html-form": "dispatch_html_form",
"render-lambda-html": "render_lambda_html",
"make-raw-html": "make_raw_html",
"render-html-island": "render_html_island",
"serialize-island-state": "serialize_island_state",
"json-serialize": "json_serialize",
"empty-dict?": "is_empty_dict",
"sf-defisland": "sf_defisland",
# adapter-sx.sx
"render-to-sx": "render_to_sx",
"aser": "aser",
@@ -379,11 +410,26 @@ class PyEmitter:
params = expr[1]
body = expr[2:]
param_names = []
for p in params:
rest_name = None
i = 0
while i < len(params):
p = params[i]
if isinstance(p, Symbol) and p.name == "&rest":
# Next param is the rest parameter
if i + 1 < len(params):
rest_name = self._mangle(params[i + 1].name if isinstance(params[i + 1], Symbol) else str(params[i + 1]))
i += 2
continue
else:
i += 1
continue
if isinstance(p, Symbol):
param_names.append(self._mangle(p.name))
else:
param_names.append(str(p))
i += 1
if rest_name:
param_names.append(f"*{rest_name}")
params_str = ", ".join(param_names)
if len(body) == 1:
body_py = self.emit(body[0])
@@ -867,6 +913,7 @@ SPEC_MODULES = {
"deps": ("deps.sx", "deps (component dependency analysis)"),
"router": ("router.sx", "router (client-side route matching)"),
"engine": ("engine.sx", "engine (fetch/swap/trigger pure logic)"),
"signals": ("signals.sx", "signals (reactive signal runtime)"),
}
@@ -1005,6 +1052,9 @@ def compile_ref_to_py(
raise ValueError(f"Unknown spec module: {sm!r}. Valid: {', '.join(SPEC_MODULES)}")
spec_mod_set.add(sm)
has_deps = "deps" in spec_mod_set
# html adapter uses signal runtime for server-side island rendering
if "html" in adapter_set and "signals" in SPEC_MODULES:
spec_mod_set.add("signals")
# Core files always included, then selected adapters, then spec modules
sx_files = [
@@ -1092,7 +1142,7 @@ from typing import Any
# =========================================================================
from shared.sx.types import (
NIL, Symbol, Keyword, Lambda, Component, Continuation, Macro,
NIL, Symbol, Keyword, Lambda, Component, Island, Continuation, Macro,
HandlerDef, QueryDef, ActionDef, PageDef, _ShiftSignal,
)
from shared.sx.parser import SxExpr
@@ -1197,6 +1247,10 @@ def type_of(x):
return "lambda"
if isinstance(x, Component):
return "component"
if isinstance(x, Island):
return "island"
if isinstance(x, _Signal):
return "signal"
if isinstance(x, Macro):
return "macro"
if isinstance(x, _RawHTML):
@@ -1235,6 +1289,11 @@ def make_component(name, params, has_children, body, env, affinity="auto"):
body=body, closure=dict(env), affinity=str(affinity) if affinity else "auto")
def make_island(name, params, has_children, body, env):
return Island(name=name, params=list(params), has_children=has_children,
body=body, closure=dict(env))
def make_macro(params, rest_param, body, env, name=None):
return Macro(params=list(params), rest_param=rest_param, body=body,
closure=dict(env), name=name)
@@ -1369,6 +1428,119 @@ def is_macro(x):
return isinstance(x, Macro)
def is_island(x):
return isinstance(x, Island)
def is_identical(a, b):
return a is b
# -------------------------------------------------------------------------
# Signal platform -- reactive state primitives
# -------------------------------------------------------------------------
class _Signal:
"""Reactive signal container."""
__slots__ = ("value", "subscribers", "deps")
def __init__(self, value):
self.value = value
self.subscribers = []
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)
def is_signal(x):
return isinstance(x, _Signal)
def signal_value(s):
return s.value if isinstance(s, _Signal) else s
def signal_set_value(s, v):
if isinstance(s, _Signal):
s.value = v
def signal_subscribers(s):
return list(s.subscribers) if isinstance(s, _Signal) else []
def signal_add_sub(s, fn):
if isinstance(s, _Signal) and fn not in s.subscribers:
s.subscribers.append(fn)
def signal_remove_sub(s, fn):
if isinstance(s, _Signal) and fn in s.subscribers:
s.subscribers.remove(fn)
def signal_deps(s):
return list(s.deps) if isinstance(s, _Signal) else []
def signal_set_deps(s, deps):
if isinstance(s, _Signal):
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 json_serialize(obj):
import json
try:
return json.dumps(obj)
except (TypeError, ValueError):
return "{}"
def is_empty_dict(d):
if not isinstance(d, dict):
return True
return len(d) == 0
def env_has(env, name):
return name in env

View File

@@ -124,3 +124,55 @@
(define-boundary-types
(list "number" "string" "boolean" "nil" "keyword"
"list" "dict" "sx-source"))
;; --------------------------------------------------------------------------
;; Tier 3: Signal primitives — reactive state for islands
;;
;; These are pure primitives (no IO) but are separated from primitives.sx
;; because they introduce a new type (signal) and depend on signals.sx.
;; --------------------------------------------------------------------------
(declare-tier :signals :source "signals.sx")
(declare-signal-primitive "signal"
:params (initial-value)
:returns "signal"
:doc "Create a reactive signal container with an initial value.")
(declare-signal-primitive "deref"
:params (signal)
:returns "any"
:doc "Read a signal's current value. In a reactive context (inside an island),
subscribes the current DOM binding to the signal. Outside reactive
context, just returns the value.")
(declare-signal-primitive "reset!"
:params (signal value)
:returns "nil"
:doc "Set a signal to a new value. Notifies all subscribers.")
(declare-signal-primitive "swap!"
:params (signal f &rest args)
:returns "nil"
:doc "Update a signal by applying f to its current value. (swap! s inc)
is equivalent to (reset! s (inc (deref s))) but atomic.")
(declare-signal-primitive "computed"
:params (compute-fn)
:returns "signal"
:doc "Create a derived signal that recomputes when its dependencies change.
Dependencies are discovered automatically by tracking deref calls.")
(declare-signal-primitive "effect"
:params (effect-fn)
:returns "lambda"
:doc "Run a side effect that re-runs when its signal dependencies change.
Returns a dispose function. If the effect function returns a function,
it is called as cleanup before the next run.")
(declare-signal-primitive "batch"
:params (thunk)
:returns "any"
:doc "Group multiple signal writes. Subscribers are notified once at the end,
after all values have been updated.")

View File

@@ -0,0 +1,182 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>SX Reactive Islands Demo</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; max-width: 640px; margin: 40px auto; padding: 0 20px; color: #1a1a2e; background: #f8f8fc; }
h1 { margin-bottom: 8px; font-size: 1.5rem; }
.subtitle { color: #666; margin-bottom: 32px; font-size: 0.9rem; }
.demo { background: white; border: 1px solid #e2e2ea; border-radius: 8px; padding: 20px; margin-bottom: 20px; }
.demo h2 { font-size: 1.1rem; margin-bottom: 12px; color: #2d2d4e; }
.demo-row { display: flex; align-items: center; gap: 12px; margin-bottom: 8px; }
button { background: #4a3f8a; color: white; border: none; border-radius: 4px; padding: 6px 16px; cursor: pointer; font-size: 0.9rem; }
button:hover { background: #5b4fa0; }
button:active { background: #3a2f7a; }
.value { font-size: 1.4rem; font-weight: 600; min-width: 3ch; text-align: center; }
.derived { color: #666; font-size: 0.85rem; }
.effect-log { background: #f0f0f8; border-radius: 4px; padding: 8px 12px; font-family: monospace; font-size: 0.8rem; max-height: 120px; overflow-y: auto; white-space: pre-wrap; }
.batch-indicator { display: inline-block; background: #e8f5e9; color: #2e7d32; padding: 2px 8px; border-radius: 3px; font-size: 0.8rem; }
code { background: #f0f0f8; padding: 2px 6px; border-radius: 3px; font-size: 0.85rem; }
.note { color: #888; font-size: 0.8rem; margin-top: 8px; }
</style>
</head>
<body>
<h1>SX Reactive Islands</h1>
<p class="subtitle">Signals transpiled from <code>signals.sx</code> spec via <code>bootstrap_js.py</code></p>
<!-- Demo 1: Basic signal -->
<div class="demo" id="demo-counter">
<h2>1. Signal: Counter</h2>
<div class="demo-row">
<button onclick="decr()">-</button>
<span class="value" id="count-display">0</span>
<button onclick="incr()">+</button>
</div>
<div class="derived" id="doubled-display"></div>
<p class="note"><code>signal</code> + <code>computed</code> + <code>effect</code></p>
</div>
<!-- Demo 2: Batch -->
<div class="demo" id="demo-batch">
<h2>2. Batch: Two signals, one notification</h2>
<div class="demo-row">
<span>first: <strong id="first-display">0</strong></span>
<span>second: <strong id="second-display">0</strong></span>
<span class="batch-indicator" id="render-count"></span>
</div>
<div class="demo-row">
<button onclick="batchBoth()">Batch increment both</button>
<button onclick="noBatchBoth()">No-batch increment both</button>
</div>
<p class="note"><code>batch</code> coalesces writes: 2 updates, 1 re-render</p>
</div>
<!-- Demo 3: Effect with cleanup -->
<div class="demo" id="demo-effect">
<h2>3. Effect: Auto-tracking + Cleanup</h2>
<div class="demo-row">
<button onclick="togglePolling()">Toggle polling</button>
<span id="poll-status"></span>
</div>
<div class="effect-log" id="effect-log"></div>
<p class="note"><code>effect</code> returns cleanup fn; dispose stops tracking</p>
</div>
<!-- Demo 4: Computed chains -->
<div class="demo" id="demo-chain">
<h2>4. Computed chain: base &rarr; doubled &rarr; quadrupled</h2>
<div class="demo-row">
<button onclick="chainDecr()">-</button>
<span>base: <strong id="chain-base">1</strong></span>
<button onclick="chainIncr()">+</button>
</div>
<div class="derived">
doubled: <strong id="chain-doubled"></strong> &nbsp;
quadrupled: <strong id="chain-quad"></strong>
</div>
<p class="note">Three-level computed dependency graph, auto-propagation</p>
</div>
<script src="sx-ref.js"></script>
<script>
// Grab signal primitives from transpiled runtime
var S = window.Sx;
var signal = S.signal;
var deref = S.deref;
var reset = S.reset;
var swap = S.swap;
var computed = S.computed;
var effect = S.effect;
var batch = S.batch;
// ---- Demo 1: Counter ----
var count = signal(0);
var doubled = computed(function() { return deref(count) * 2; });
effect(function() {
document.getElementById("count-display").textContent = deref(count);
});
effect(function() {
document.getElementById("doubled-display").textContent = "doubled: " + deref(doubled);
});
function incr() { swap(count, function(n) { return n + 1; }); }
function decr() { swap(count, function(n) { return n - 1; }); }
// ---- Demo 2: Batch ----
var first = signal(0);
var second = signal(0);
var renders = signal(0);
effect(function() {
document.getElementById("first-display").textContent = deref(first);
document.getElementById("second-display").textContent = deref(second);
swap(renders, function(n) { return n + 1; });
});
effect(function() {
document.getElementById("render-count").textContent = "renders: " + deref(renders);
});
function batchBoth() {
batch(function() {
swap(first, function(n) { return n + 1; });
swap(second, function(n) { return n + 1; });
});
}
function noBatchBoth() {
swap(first, function(n) { return n + 1; });
swap(second, function(n) { return n + 1; });
}
// ---- Demo 3: Effect with cleanup ----
var polling = signal(false);
var pollDispose = null;
var logEl = document.getElementById("effect-log");
function log(msg) {
logEl.textContent += msg + "\n";
logEl.scrollTop = logEl.scrollHeight;
}
effect(function() {
var active = deref(polling);
document.getElementById("poll-status").textContent = active ? "polling..." : "stopped";
if (active) {
var n = 0;
var id = setInterval(function() {
n++;
log("poll #" + n);
}, 500);
log("effect: started interval");
// Return cleanup function
return function() {
clearInterval(id);
log("cleanup: cleared interval");
};
}
});
function togglePolling() { swap(polling, function(v) { return !v; }); }
// ---- Demo 4: Computed chain ----
var base = signal(1);
var chainDoubled = computed(function() { return deref(base) * 2; });
var quadrupled = computed(function() { return deref(chainDoubled) * 2; });
effect(function() {
document.getElementById("chain-base").textContent = deref(base);
});
effect(function() {
document.getElementById("chain-doubled").textContent = deref(chainDoubled);
});
effect(function() {
document.getElementById("chain-quad").textContent = deref(quadrupled);
});
function chainIncr() { swap(base, function(n) { return n + 1; }); }
function chainDecr() { swap(base, function(n) { return n - 1; }); }
</script>
</body>
</html>

View File

@@ -35,12 +35,14 @@
;; lambda — closure: {params, body, closure-env, name?}
;; macro — AST transformer: {params, rest-param, body, closure-env}
;; component — UI component: {name, params, has-children, body, closure-env}
;; island — reactive component: like component but with island flag
;; thunk — deferred eval for TCO: {expr, env}
;;
;; Each target must provide:
;; (type-of x) → one of the strings above
;; (make-lambda ...) → platform Lambda value
;; (make-component ..) → platform Component value
;; (make-island ...) → platform Island value (component + island flag)
;; (make-macro ...) → platform Macro value
;; (make-thunk ...) → platform Thunk value
;;
@@ -141,6 +143,7 @@
(= name "fn") (sf-lambda args env)
(= name "define") (sf-define args env)
(= name "defcomp") (sf-defcomp args env)
(= name "defisland") (sf-defisland args env)
(= name "defmacro") (sf-defmacro args env)
(= name "defstyle") (sf-defstyle args env)
(= name "defhandler") (sf-defhandler args env)
@@ -192,7 +195,7 @@
(evaluated-args (map (fn (a) (trampoline (eval-expr a env))) args)))
(cond
;; Native callable (primitive function)
(and (callable? f) (not (lambda? f)) (not (component? f)))
(and (callable? f) (not (lambda? f)) (not (component? f)) (not (island? f)))
(apply f evaluated-args)
;; Lambda
@@ -203,6 +206,10 @@
(component? f)
(call-component f args env)
;; Island (reactive component) — same calling convention
(island? f)
(call-component f args env)
:else (error (str "Not callable: " (inspect f)))))))
@@ -543,6 +550,24 @@
(list params has-children))))
(define sf-defisland
(fn (args env)
;; (defisland ~name (params) body)
;; Like defcomp but creates an island (reactive component).
;; Islands have the same calling convention as components but
;; render with a reactive context on the client.
(let ((name-sym (first args))
(params-raw (nth args 1))
(body (last args))
(comp-name (strip-prefix (symbol-name name-sym) "~"))
(parsed (parse-comp-params params-raw))
(params (first parsed))
(has-children (nth parsed 1)))
(let ((island (make-island comp-name params has-children body env)))
(env-set! env (symbol-name name-sym) island)
island))))
(define sf-defmacro
(fn (args env)
(let ((name-sym (first args))
@@ -903,6 +928,11 @@
;; (component-closure c) → env
;; (component-has-children? c) → boolean
;; (component-affinity c) → "auto" | "client" | "server"
;;
;; (make-island name params has-children body env) → Island
;; (island? x) → boolean
;; ;; Islands reuse component accessors: component-params, component-body, etc.
;;
;; (macro-params m) → list of strings
;; (macro-rest-param m) → string or nil
;; (macro-body m) → expr
@@ -915,6 +945,7 @@
;; (callable? x) → boolean (native function or lambda)
;; (lambda? x) → boolean
;; (component? x) → boolean
;; (island? x) → boolean
;; (macro? x) → boolean
;; (primitive? name) → boolean (is name a registered primitive?)
;; (get-primitive name) → function

View File

@@ -73,8 +73,8 @@
(define definition-form?
(fn (name)
(or (= name "define") (= name "defcomp") (= name "defmacro")
(= name "defstyle") (= name "defhandler"))))
(or (= name "define") (= name "defcomp") (= name "defisland")
(= name "defmacro") (= name "defstyle") (= name "defhandler"))))
(define parse-element-args

290
shared/sx/ref/signals.sx Normal file
View File

@@ -0,0 +1,290 @@
;; ==========================================================================
;; signals.sx — Reactive signal runtime specification
;;
;; Defines the signal primitive: a container for a value that notifies
;; subscribers when it changes. Signals are the reactive state primitive
;; for SX islands.
;;
;; Signals are pure computation — no DOM, no IO. The reactive rendering
;; layer (adapter-dom.sx) subscribes DOM nodes to signals. The server
;; adapter (adapter-html.sx) reads signal values without subscribing.
;;
;; Platform interface required:
;; (make-signal value) → Signal — create signal container
;; (signal? x) → boolean — type predicate
;; (signal-value s) → any — read current value (no tracking)
;; (signal-set-value! s v) → void — write value (no notification)
;; (signal-subscribers s) → list — list of subscriber fns
;; (signal-add-sub! s fn) → void — add subscriber
;; (signal-remove-sub! s fn) → void — remove subscriber
;; (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
;;
;; ==========================================================================
;; --------------------------------------------------------------------------
;; 1. signal — create a reactive container
;; --------------------------------------------------------------------------
(define signal
(fn (initial-value)
(make-signal initial-value)))
;; --------------------------------------------------------------------------
;; 2. deref — read signal value, subscribe current reactive context
;; --------------------------------------------------------------------------
;;
;; In a reactive context (inside effect or computed), deref registers the
;; signal as a dependency. Outside reactive context, deref just returns
;; the current value — no subscription, no overhead.
(define deref
(fn (s)
(if (not (signal? s))
s ;; non-signal values pass through
(let ((ctx (get-tracking-context)))
(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)))
(signal-value s)))))
;; --------------------------------------------------------------------------
;; 3. reset! — write a new value, notify subscribers
;; --------------------------------------------------------------------------
(define reset!
(fn (s value)
(when (signal? s)
(let ((old (signal-value s)))
(when (not (identical? old value))
(signal-set-value! s value)
(notify-subscribers s))))))
;; --------------------------------------------------------------------------
;; 4. swap! — update signal via function
;; --------------------------------------------------------------------------
(define swap!
(fn (s f &rest args)
(when (signal? s)
(let ((old (signal-value s))
(new-val (apply f (cons old args))))
(when (not (identical? old new-val))
(signal-set-value! s new-val)
(notify-subscribers s))))))
;; --------------------------------------------------------------------------
;; 5. computed — derived signal with automatic dependency tracking
;; --------------------------------------------------------------------------
;;
;; A computed signal wraps a zero-arg function. It re-evaluates when any
;; of its dependencies change. The dependency set is discovered automatically
;; by tracking deref calls during evaluation.
(define computed
(fn (compute-fn)
(let ((s (make-signal nil))
(deps (list))
(compute-ctx nil))
;; The notify function — called when a dependency changes
(let ((recompute
(fn ()
;; Unsubscribe from old deps
(for-each
(fn (dep) (signal-remove-sub! dep recompute))
(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 (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)))))))))
;; Initial computation
(recompute)
s))))
;; --------------------------------------------------------------------------
;; 6. effect — side effect that runs when dependencies change
;; --------------------------------------------------------------------------
;;
;; Like computed, but doesn't produce a signal value. Returns a dispose
;; function that tears down the effect.
(define effect
(fn (effect-fn)
(let ((deps (list))
(disposed false)
(cleanup-fn nil))
(let ((run-effect
(fn ()
(when (not disposed)
;; Run previous cleanup if any
(when cleanup-fn (cleanup-fn))
;; Unsubscribe from old deps
(for-each
(fn (dep) (signal-remove-sub! dep run-effect))
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 (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)))))))))
;; Initial run
(run-effect)
;; Return dispose function
(fn ()
(set! disposed true)
(when cleanup-fn (cleanup-fn))
(for-each
(fn (dep) (signal-remove-sub! dep run-effect))
deps)
(set! deps (list)))))))
;; --------------------------------------------------------------------------
;; 7. batch — group multiple signal writes into one notification pass
;; --------------------------------------------------------------------------
;;
;; During a batch, signal writes are deferred. Subscribers are notified
;; once at the end, after all values have been updated.
(define *batch-depth* 0)
(define *batch-queue* (list))
(define batch
(fn (thunk)
(set! *batch-depth* (+ *batch-depth* 1))
(thunk)
(set! *batch-depth* (- *batch-depth* 1))
(when (= *batch-depth* 0)
(let ((queue *batch-queue*))
(set! *batch-queue* (list))
;; Collect unique subscribers across all queued signals,
;; then notify each exactly once.
(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))))))
;; --------------------------------------------------------------------------
;; 8. notify-subscribers — internal notification dispatch
;; --------------------------------------------------------------------------
;;
;; If inside a batch, queues the signal. Otherwise, notifies immediately.
(define notify-subscribers
(fn (s)
(if (> *batch-depth* 0)
(when (not (contains? *batch-queue* s))
(append! *batch-queue* s))
(flush-subscribers s))))
(define flush-subscribers
(fn (s)
(for-each
(fn (sub) (sub))
(signal-subscribers s))))
;; --------------------------------------------------------------------------
;; 9. 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).
;; --------------------------------------------------------------------------
;; 10. dispose — tear down a computed signal
;; --------------------------------------------------------------------------
;;
;; For computed signals, unsubscribe from all dependencies.
;; For effects, the dispose function is returned by effect itself.
(define dispose-computed
(fn (s)
(when (signal? s)
(for-each
(fn (dep) (signal-remove-sub! dep nil))
(signal-deps s))
(signal-set-deps! s (list)))))
;; --------------------------------------------------------------------------
;; 11. Island scope — automatic cleanup of signals within an island
;; --------------------------------------------------------------------------
;;
;; 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)
(define with-island-scope
(fn (scope-fn body-fn)
(let ((prev *island-scope*))
(set! *island-scope* scope-fn)
(let ((result (body-fn)))
(set! *island-scope* prev)
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
(fn (disposable)
(when *island-scope*
(*island-scope* disposable))))

View File

@@ -182,6 +182,23 @@
(when subtitle (p subtitle))
children))")
(define-special-form "defisland"
:syntax (defisland ~name (&key param1 param2 &rest children) body)
:doc "Define a reactive island. Islands have the same calling convention
as components (defcomp) but create a reactive boundary. Inside an
island, signals are tracked — deref subscribes DOM nodes to signals,
and signal changes update only the affected nodes.
On the server, islands render as static HTML wrapped in a
data-sx-island container with serialized initial state. On the
client, islands hydrate into reactive contexts."
:tail-position "body"
:example "(defisland ~counter (&key initial)
(let ((count (signal (or initial 0))))
(div :class \"counter\"
(span (deref count))
(button :on-click (fn (e) (swap! count inc)) \"+\"))))")
(define-special-form "defmacro"
:syntax (defmacro name (params ...) body)
:doc "Define a macro. Macros receive their arguments unevaluated (as raw

View File

@@ -18,7 +18,7 @@ from typing import Any
# =========================================================================
from shared.sx.types import (
NIL, Symbol, Keyword, Lambda, Component, Continuation, Macro,
NIL, Symbol, Keyword, Lambda, Component, Island, Continuation, Macro,
HandlerDef, QueryDef, ActionDef, PageDef, _ShiftSignal,
)
from shared.sx.parser import SxExpr
@@ -122,6 +122,10 @@ def type_of(x):
return "lambda"
if isinstance(x, Component):
return "component"
if isinstance(x, Island):
return "island"
if isinstance(x, _Signal):
return "signal"
if isinstance(x, Macro):
return "macro"
if isinstance(x, _RawHTML):
@@ -160,6 +164,11 @@ def make_component(name, params, has_children, body, env, affinity="auto"):
body=body, closure=dict(env), affinity=str(affinity) if affinity else "auto")
def make_island(name, params, has_children, body, env):
return Island(name=name, params=list(params), has_children=has_children,
body=body, closure=dict(env))
def make_macro(params, rest_param, body, env, name=None):
return Macro(params=list(params), rest_param=rest_param, body=body,
closure=dict(env), name=name)
@@ -294,6 +303,119 @@ def is_macro(x):
return isinstance(x, Macro)
def is_island(x):
return isinstance(x, Island)
def is_identical(a, b):
return a is b
# -------------------------------------------------------------------------
# Signal platform -- reactive state primitives
# -------------------------------------------------------------------------
class _Signal:
"""Reactive signal container."""
__slots__ = ("value", "subscribers", "deps")
def __init__(self, value):
self.value = value
self.subscribers = []
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)
def is_signal(x):
return isinstance(x, _Signal)
def signal_value(s):
return s.value if isinstance(s, _Signal) else s
def signal_set_value(s, v):
if isinstance(s, _Signal):
s.value = v
def signal_subscribers(s):
return list(s.subscribers) if isinstance(s, _Signal) else []
def signal_add_sub(s, fn):
if isinstance(s, _Signal) and fn not in s.subscribers:
s.subscribers.append(fn)
def signal_remove_sub(s, fn):
if isinstance(s, _Signal) and fn in s.subscribers:
s.subscribers.remove(fn)
def signal_deps(s):
return list(s.deps) if isinstance(s, _Signal) else []
def signal_set_deps(s, deps):
if isinstance(s, _Signal):
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 json_serialize(obj):
import json
try:
return json.dumps(obj)
except (TypeError, ValueError):
return "{}"
def is_empty_dict(d):
if not isinstance(d, dict):
return True
return len(d) == 0
def env_has(env, name):
return name in env
@@ -890,54 +1012,6 @@ has_key_p = PRIMITIVES["has-key?"]
dissoc = PRIMITIVES["dissoc"]
# =========================================================================
# Platform: deps module — component dependency analysis
# =========================================================================
import re as _re
def component_deps(c):
"""Return cached deps list for a component (may be empty)."""
return list(c.deps) if hasattr(c, "deps") and c.deps else []
def component_set_deps(c, deps):
"""Cache deps on a component."""
c.deps = set(deps) if not isinstance(deps, set) else deps
def component_css_classes(c):
"""Return pre-scanned CSS class list for a component."""
return list(c.css_classes) if hasattr(c, "css_classes") and c.css_classes else []
def env_components(env):
"""Return list of component/macro names in an environment."""
return [k for k, v in env.items()
if isinstance(v, (Component, Macro))]
def regex_find_all(pattern, source):
"""Return list of capture group 1 matches."""
return [m.group(1) for m in _re.finditer(pattern, source)]
def scan_css_classes(source):
"""Extract CSS class strings from SX source."""
classes = set()
for m in _re.finditer(r':class\s+"([^"]*)"', source):
classes.update(m.group(1).split())
for m in _re.finditer(r':class\s+\(str\s+((?:"[^"]*"\s*)+)\)', source):
for s in _re.findall(r'"([^"]*)"', m.group(1)):
classes.update(s.split())
for m in _re.finditer(r';;\s*@css\s+(.+)', source):
classes.update(m.group(1).split())
return list(classes)
def component_io_refs(c):
"""Return cached IO refs list for a component (may be empty)."""
return list(c.io_refs) if hasattr(c, "io_refs") and c.io_refs else []
def component_set_io_refs(c, refs):
"""Cache IO refs on a component."""
c.io_refs = set(refs) if not isinstance(refs, set) else refs
# === Transpiled from eval ===
# trampoline
@@ -947,10 +1021,10 @@ trampoline = lambda val: (lambda result: (trampoline(eval_expr(thunk_expr(result
eval_expr = lambda expr, env: _sx_case(type_of(expr), [('number', lambda: expr), ('string', lambda: expr), ('boolean', lambda: expr), ('nil', lambda: NIL), ('symbol', lambda: (lambda name: (env_get(env, name) if sx_truthy(env_has(env, name)) else (get_primitive(name) if sx_truthy(is_primitive(name)) else (True if sx_truthy((name == 'true')) else (False if sx_truthy((name == 'false')) else (NIL if sx_truthy((name == 'nil')) else error(sx_str('Undefined symbol: ', name))))))))(symbol_name(expr))), ('keyword', lambda: keyword_name(expr)), ('dict', lambda: map_dict(lambda k, v: trampoline(eval_expr(v, env)), expr)), ('list', lambda: ([] if sx_truthy(empty_p(expr)) else eval_list(expr, env))), (None, lambda: expr)])
# eval-list
eval_list = lambda expr, env: (lambda head: (lambda args: (map(lambda x: trampoline(eval_expr(x, env)), expr) if sx_truthy((not sx_truthy(((type_of(head) == 'symbol') if sx_truthy((type_of(head) == 'symbol')) else ((type_of(head) == 'lambda') if sx_truthy((type_of(head) == 'lambda')) else (type_of(head) == 'list')))))) else ((lambda name: (sf_if(args, env) if sx_truthy((name == 'if')) else (sf_when(args, env) if sx_truthy((name == 'when')) else (sf_cond(args, env) if sx_truthy((name == 'cond')) else (sf_case(args, env) if sx_truthy((name == 'case')) else (sf_and(args, env) if sx_truthy((name == 'and')) else (sf_or(args, env) if sx_truthy((name == 'or')) else (sf_let(args, env) if sx_truthy((name == 'let')) else (sf_let(args, env) if sx_truthy((name == 'let*')) else (sf_letrec(args, env) if sx_truthy((name == 'letrec')) else (sf_lambda(args, env) if sx_truthy((name == 'lambda')) else (sf_lambda(args, env) if sx_truthy((name == 'fn')) else (sf_define(args, env) if sx_truthy((name == 'define')) else (sf_defcomp(args, env) if sx_truthy((name == 'defcomp')) else (sf_defmacro(args, env) if sx_truthy((name == 'defmacro')) else (sf_defstyle(args, env) if sx_truthy((name == 'defstyle')) else (sf_defhandler(args, env) if sx_truthy((name == 'defhandler')) else (sf_defpage(args, env) if sx_truthy((name == 'defpage')) else (sf_defquery(args, env) if sx_truthy((name == 'defquery')) else (sf_defaction(args, env) if sx_truthy((name == 'defaction')) else (sf_begin(args, env) if sx_truthy((name == 'begin')) else (sf_begin(args, env) if sx_truthy((name == 'do')) else (sf_quote(args, env) if sx_truthy((name == 'quote')) else (sf_quasiquote(args, env) if sx_truthy((name == 'quasiquote')) else (sf_thread_first(args, env) if sx_truthy((name == '->')) else (sf_set_bang(args, env) if sx_truthy((name == 'set!')) else (sf_reset(args, env) if sx_truthy((name == 'reset')) else (sf_shift(args, env) if sx_truthy((name == 'shift')) else (sf_dynamic_wind(args, env) if sx_truthy((name == 'dynamic-wind')) else (ho_map(args, env) if sx_truthy((name == 'map')) else (ho_map_indexed(args, env) if sx_truthy((name == 'map-indexed')) else (ho_filter(args, env) if sx_truthy((name == 'filter')) else (ho_reduce(args, env) if sx_truthy((name == 'reduce')) else (ho_some(args, env) if sx_truthy((name == 'some')) else (ho_every(args, env) if sx_truthy((name == 'every?')) else (ho_for_each(args, env) if sx_truthy((name == 'for-each')) else ((lambda mac: make_thunk(expand_macro(mac, args, env), env))(env_get(env, name)) if sx_truthy((env_has(env, name) if not sx_truthy(env_has(env, name)) else is_macro(env_get(env, name)))) else (render_expr(expr, env) if sx_truthy(is_render_expr(expr)) else eval_call(head, args, env)))))))))))))))))))))))))))))))))))))))(symbol_name(head)) if sx_truthy((type_of(head) == 'symbol')) else eval_call(head, args, env))))(rest(expr)))(first(expr))
eval_list = lambda expr, env: (lambda head: (lambda args: (map(lambda x: trampoline(eval_expr(x, env)), expr) if sx_truthy((not sx_truthy(((type_of(head) == 'symbol') if sx_truthy((type_of(head) == 'symbol')) else ((type_of(head) == 'lambda') if sx_truthy((type_of(head) == 'lambda')) else (type_of(head) == 'list')))))) else ((lambda name: (sf_if(args, env) if sx_truthy((name == 'if')) else (sf_when(args, env) if sx_truthy((name == 'when')) else (sf_cond(args, env) if sx_truthy((name == 'cond')) else (sf_case(args, env) if sx_truthy((name == 'case')) else (sf_and(args, env) if sx_truthy((name == 'and')) else (sf_or(args, env) if sx_truthy((name == 'or')) else (sf_let(args, env) if sx_truthy((name == 'let')) else (sf_let(args, env) if sx_truthy((name == 'let*')) else (sf_letrec(args, env) if sx_truthy((name == 'letrec')) else (sf_lambda(args, env) if sx_truthy((name == 'lambda')) else (sf_lambda(args, env) if sx_truthy((name == 'fn')) else (sf_define(args, env) if sx_truthy((name == 'define')) else (sf_defcomp(args, env) if sx_truthy((name == 'defcomp')) else (sf_defisland(args, env) if sx_truthy((name == 'defisland')) else (sf_defmacro(args, env) if sx_truthy((name == 'defmacro')) else (sf_defstyle(args, env) if sx_truthy((name == 'defstyle')) else (sf_defhandler(args, env) if sx_truthy((name == 'defhandler')) else (sf_defpage(args, env) if sx_truthy((name == 'defpage')) else (sf_defquery(args, env) if sx_truthy((name == 'defquery')) else (sf_defaction(args, env) if sx_truthy((name == 'defaction')) else (sf_begin(args, env) if sx_truthy((name == 'begin')) else (sf_begin(args, env) if sx_truthy((name == 'do')) else (sf_quote(args, env) if sx_truthy((name == 'quote')) else (sf_quasiquote(args, env) if sx_truthy((name == 'quasiquote')) else (sf_thread_first(args, env) if sx_truthy((name == '->')) else (sf_set_bang(args, env) if sx_truthy((name == 'set!')) else (sf_reset(args, env) if sx_truthy((name == 'reset')) else (sf_shift(args, env) if sx_truthy((name == 'shift')) else (sf_dynamic_wind(args, env) if sx_truthy((name == 'dynamic-wind')) else (ho_map(args, env) if sx_truthy((name == 'map')) else (ho_map_indexed(args, env) if sx_truthy((name == 'map-indexed')) else (ho_filter(args, env) if sx_truthy((name == 'filter')) else (ho_reduce(args, env) if sx_truthy((name == 'reduce')) else (ho_some(args, env) if sx_truthy((name == 'some')) else (ho_every(args, env) if sx_truthy((name == 'every?')) else (ho_for_each(args, env) if sx_truthy((name == 'for-each')) else ((lambda mac: make_thunk(expand_macro(mac, args, env), env))(env_get(env, name)) if sx_truthy((env_has(env, name) if not sx_truthy(env_has(env, name)) else is_macro(env_get(env, name)))) else (render_expr(expr, env) if sx_truthy(is_render_expr(expr)) else eval_call(head, args, env))))))))))))))))))))))))))))))))))))))))(symbol_name(head)) if sx_truthy((type_of(head) == 'symbol')) else eval_call(head, args, env))))(rest(expr)))(first(expr))
# eval-call
eval_call = lambda head, args, env: (lambda f: (lambda evaluated_args: (apply(f, evaluated_args) if sx_truthy((is_callable(f) if not sx_truthy(is_callable(f)) else ((not sx_truthy(is_lambda(f))) if not sx_truthy((not sx_truthy(is_lambda(f)))) else (not sx_truthy(is_component(f)))))) else (call_lambda(f, evaluated_args, env) if sx_truthy(is_lambda(f)) else (call_component(f, args, env) if sx_truthy(is_component(f)) else error(sx_str('Not callable: ', inspect(f)))))))(map(lambda a: trampoline(eval_expr(a, env)), args)))(trampoline(eval_expr(head, env)))
eval_call = lambda head, args, env: (lambda f: (lambda evaluated_args: (apply(f, evaluated_args) if sx_truthy((is_callable(f) if not sx_truthy(is_callable(f)) else ((not sx_truthy(is_lambda(f))) if not sx_truthy((not sx_truthy(is_lambda(f)))) else ((not sx_truthy(is_component(f))) if not sx_truthy((not sx_truthy(is_component(f)))) else (not sx_truthy(is_island(f))))))) else (call_lambda(f, evaluated_args, env) if sx_truthy(is_lambda(f)) else (call_component(f, args, env) if sx_truthy(is_component(f)) else (call_component(f, args, env) if sx_truthy(is_island(f)) else error(sx_str('Not callable: ', inspect(f))))))))(map(lambda a: trampoline(eval_expr(a, env)), args)))(trampoline(eval_expr(head, env)))
# call-lambda
call_lambda = lambda f, args, caller_env: (lambda params: (lambda local: (error(sx_str((lambda_name(f) if sx_truthy(lambda_name(f)) else 'lambda'), ' expects ', len(params), ' args, got ', len(args))) if sx_truthy((len(args) != len(params))) else _sx_begin(for_each(lambda pair: _sx_dict_set(local, first(pair), nth(pair, 1)), zip(params, args)), make_thunk(lambda_body(f), local))))(env_merge(lambda_closure(f), caller_env)))(lambda_params(f))
@@ -1040,6 +1114,9 @@ def parse_comp_params(params_expr):
params.append(name)
return [params, _cells['has_children']]
# sf-defisland
sf_defisland = lambda args, env: (lambda name_sym: (lambda params_raw: (lambda body: (lambda comp_name: (lambda parsed: (lambda params: (lambda has_children: (lambda island: _sx_begin(_sx_dict_set(env, symbol_name(name_sym), island), island))(make_island(comp_name, params, has_children, body, env)))(nth(parsed, 1)))(first(parsed)))(parse_comp_params(params_raw)))(strip_prefix(symbol_name(name_sym), '~')))(last(args)))(nth(args, 1)))(first(args))
# sf-defmacro
sf_defmacro = lambda args, env: (lambda name_sym: (lambda params_raw: (lambda body: (lambda parsed: (lambda params: (lambda rest_param: (lambda mac: _sx_begin(_sx_dict_set(env, symbol_name(name_sym), mac), mac))(make_macro(params, rest_param, body, env, symbol_name(name_sym))))(nth(parsed, 1)))(first(parsed)))(parse_macro_params(params_raw)))(nth(args, 2)))(nth(args, 1)))(first(args))
@@ -1164,7 +1241,7 @@ VOID_ELEMENTS = ['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'li
BOOLEAN_ATTRS = ['async', 'autofocus', 'autoplay', 'checked', 'controls', 'default', 'defer', 'disabled', 'formnovalidate', 'hidden', 'inert', 'ismap', 'loop', 'multiple', 'muted', 'nomodule', 'novalidate', 'open', 'playsinline', 'readonly', 'required', 'reversed', 'selected']
# definition-form?
is_definition_form = lambda name: ((name == 'define') if sx_truthy((name == 'define')) else ((name == 'defcomp') if sx_truthy((name == 'defcomp')) else ((name == 'defmacro') if sx_truthy((name == 'defmacro')) else ((name == 'defstyle') if sx_truthy((name == 'defstyle')) else (name == 'defhandler')))))
is_definition_form = lambda name: ((name == 'define') if sx_truthy((name == 'define')) else ((name == 'defcomp') if sx_truthy((name == 'defcomp')) else ((name == 'defisland') if sx_truthy((name == 'defisland')) else ((name == 'defmacro') if sx_truthy((name == 'defmacro')) else ((name == 'defstyle') if sx_truthy((name == 'defstyle')) else (name == 'defhandler'))))))
# parse-element-args
parse_element_args = lambda args, env: (lambda attrs: (lambda children: _sx_begin(reduce(lambda state, arg: (lambda skip: (assoc(state, 'skip', False, 'i', (get(state, 'i') + 1)) if sx_truthy(skip) else ((lambda val: _sx_begin(_sx_dict_set(attrs, keyword_name(arg), val), assoc(state, 'skip', True, 'i', (get(state, 'i') + 1))))(trampoline(eval_expr(nth(args, (get(state, 'i') + 1)), env))) if sx_truthy(((type_of(arg) == 'keyword') if not sx_truthy((type_of(arg) == 'keyword')) else ((get(state, 'i') + 1) < len(args)))) else _sx_begin(_sx_append(children, arg), assoc(state, 'i', (get(state, 'i') + 1))))))(get(state, 'skip')), {'i': 0, 'skip': False}, args), [attrs, children]))([]))({})
@@ -1194,13 +1271,13 @@ render_to_html = lambda expr, env: _sx_case(type_of(expr), [('nil', lambda: ''),
render_value_to_html = lambda val, env: _sx_case(type_of(val), [('nil', lambda: ''), ('string', lambda: escape_html(val)), ('number', lambda: sx_str(val)), ('boolean', lambda: ('true' if sx_truthy(val) else 'false')), ('list', lambda: render_list_to_html(val, env)), ('raw-html', lambda: raw_html_content(val)), (None, lambda: escape_html(sx_str(val)))])
# RENDER_HTML_FORMS
RENDER_HTML_FORMS = ['if', 'when', 'cond', 'case', 'let', 'let*', 'begin', 'do', 'define', 'defcomp', 'defmacro', 'defstyle', 'defhandler', 'map', 'map-indexed', 'filter', 'for-each']
RENDER_HTML_FORMS = ['if', 'when', 'cond', 'case', 'let', 'let*', 'begin', 'do', 'define', 'defcomp', 'defisland', 'defmacro', 'defstyle', 'defhandler', 'map', 'map-indexed', 'filter', 'for-each']
# render-html-form?
is_render_html_form = lambda name: contains_p(RENDER_HTML_FORMS, name)
# render-list-to-html
render_list_to_html = lambda expr, env: ('' if sx_truthy(empty_p(expr)) else (lambda head: (join('', map(lambda x: render_value_to_html(x, env), expr)) if sx_truthy((not sx_truthy((type_of(head) == 'symbol')))) else (lambda name: (lambda args: (join('', map(lambda x: render_to_html(x, env), args)) if sx_truthy((name == '<>')) else (join('', map(lambda x: sx_str(trampoline(eval_expr(x, env))), args)) if sx_truthy((name == 'raw!')) else (render_html_element(name, args, env) if sx_truthy(contains_p(HTML_TAGS, name)) else ((lambda val: (render_html_component(val, args, env) if sx_truthy(is_component(val)) else (render_to_html(expand_macro(val, args, env), env) if sx_truthy(is_macro(val)) else error(sx_str('Unknown component: ', name)))))(env_get(env, name)) if sx_truthy(starts_with_p(name, '~')) else (dispatch_html_form(name, expr, env) if sx_truthy(is_render_html_form(name)) else (render_to_html(expand_macro(env_get(env, name), args, env), env) if sx_truthy((env_has(env, name) if not sx_truthy(env_has(env, name)) else is_macro(env_get(env, name)))) else render_value_to_html(trampoline(eval_expr(expr, env)), env))))))))(rest(expr)))(symbol_name(head))))(first(expr)))
render_list_to_html = lambda expr, env: ('' if sx_truthy(empty_p(expr)) else (lambda head: (join('', map(lambda x: render_value_to_html(x, env), expr)) if sx_truthy((not sx_truthy((type_of(head) == 'symbol')))) else (lambda name: (lambda args: (join('', map(lambda x: render_to_html(x, env), args)) if sx_truthy((name == '<>')) else (join('', map(lambda x: sx_str(trampoline(eval_expr(x, env))), args)) if sx_truthy((name == 'raw!')) else (render_html_element(name, args, env) if sx_truthy(contains_p(HTML_TAGS, name)) else (render_html_island(env_get(env, name), args, env) if sx_truthy((starts_with_p(name, '~') if not sx_truthy(starts_with_p(name, '~')) else (env_has(env, name) if not sx_truthy(env_has(env, name)) else is_island(env_get(env, name))))) else ((lambda val: (render_html_component(val, args, env) if sx_truthy(is_component(val)) else (render_to_html(expand_macro(val, args, env), env) if sx_truthy(is_macro(val)) else error(sx_str('Unknown component: ', name)))))(env_get(env, name)) if sx_truthy(starts_with_p(name, '~')) else (dispatch_html_form(name, expr, env) if sx_truthy(is_render_html_form(name)) else (render_to_html(expand_macro(env_get(env, name), args, env), env) if sx_truthy((env_has(env, name) if not sx_truthy(env_has(env, name)) else is_macro(env_get(env, name)))) else render_value_to_html(trampoline(eval_expr(expr, env)), env)))))))))(rest(expr)))(symbol_name(head))))(first(expr)))
# dispatch-html-form
dispatch_html_form = lambda name, expr, env: ((lambda cond_val: (render_to_html(nth(expr, 2), env) if sx_truthy(cond_val) else (render_to_html(nth(expr, 3), env) if sx_truthy((len(expr) > 3)) else '')))(trampoline(eval_expr(nth(expr, 1), env))) if sx_truthy((name == 'if')) else (('' if sx_truthy((not sx_truthy(trampoline(eval_expr(nth(expr, 1), env))))) else join('', map(lambda i: render_to_html(nth(expr, i), env), range(2, len(expr))))) if sx_truthy((name == 'when')) else ((lambda branch: (render_to_html(branch, env) if sx_truthy(branch) else ''))(eval_cond(rest(expr), env)) if sx_truthy((name == 'cond')) else (render_to_html(trampoline(eval_expr(expr, env)), env) if sx_truthy((name == 'case')) else ((lambda local: join('', map(lambda i: render_to_html(nth(expr, i), local), range(2, len(expr)))))(process_bindings(nth(expr, 1), env)) if sx_truthy(((name == 'let') if sx_truthy((name == 'let')) else (name == 'let*'))) else (join('', map(lambda i: render_to_html(nth(expr, i), env), range(1, len(expr)))) if sx_truthy(((name == 'begin') if sx_truthy((name == 'begin')) else (name == 'do'))) else (_sx_begin(trampoline(eval_expr(expr, env)), '') if sx_truthy(is_definition_form(name)) else ((lambda f: (lambda coll: join('', map(lambda item: (render_lambda_html(f, [item], env) if sx_truthy(is_lambda(f)) else render_to_html(apply(f, [item]), env)), coll)))(trampoline(eval_expr(nth(expr, 2), env))))(trampoline(eval_expr(nth(expr, 1), env))) if sx_truthy((name == 'map')) else ((lambda f: (lambda coll: join('', map_indexed(lambda i, item: (render_lambda_html(f, [i, item], env) if sx_truthy(is_lambda(f)) else render_to_html(apply(f, [i, item]), env)), coll)))(trampoline(eval_expr(nth(expr, 2), env))))(trampoline(eval_expr(nth(expr, 1), env))) if sx_truthy((name == 'map-indexed')) else (render_to_html(trampoline(eval_expr(expr, env)), env) if sx_truthy((name == 'filter')) else ((lambda f: (lambda coll: join('', map(lambda item: (render_lambda_html(f, [item], env) if sx_truthy(is_lambda(f)) else render_to_html(apply(f, [item]), env)), coll)))(trampoline(eval_expr(nth(expr, 2), env))))(trampoline(eval_expr(nth(expr, 1), env))) if sx_truthy((name == 'for-each')) else render_value_to_html(trampoline(eval_expr(expr, env)), env))))))))))))
@@ -1214,6 +1291,12 @@ render_html_component = lambda comp, args, env: (lambda kwargs: (lambda children
# render-html-element
render_html_element = lambda tag, args, env: (lambda parsed: (lambda attrs: (lambda children: (lambda is_void: sx_str('<', tag, render_attrs(attrs), (' />' if sx_truthy(is_void) else sx_str('>', join('', map(lambda c: render_to_html(c, env), children)), '</', tag, '>'))))(contains_p(VOID_ELEMENTS, tag)))(nth(parsed, 1)))(first(parsed)))(parse_element_args(args, env))
# render-html-island
render_html_island = lambda island, args, env: (lambda kwargs: (lambda children: _sx_begin(reduce(lambda state, arg: (lambda skip: (assoc(state, 'skip', False, 'i', (get(state, 'i') + 1)) if sx_truthy(skip) else ((lambda val: _sx_begin(_sx_dict_set(kwargs, keyword_name(arg), val), assoc(state, 'skip', True, 'i', (get(state, 'i') + 1))))(trampoline(eval_expr(nth(args, (get(state, 'i') + 1)), env))) if sx_truthy(((type_of(arg) == 'keyword') if not sx_truthy((type_of(arg) == 'keyword')) else ((get(state, 'i') + 1) < len(args)))) else _sx_begin(_sx_append(children, arg), assoc(state, 'i', (get(state, 'i') + 1))))))(get(state, 'skip')), {'i': 0, 'skip': False}, args), (lambda local: (lambda island_name: _sx_begin(for_each(lambda p: _sx_dict_set(local, p, (dict_get(kwargs, p) if sx_truthy(dict_has(kwargs, p)) else NIL)), component_params(island)), (_sx_dict_set(local, 'children', make_raw_html(join('', map(lambda c: render_to_html(c, env), children)))) if sx_truthy(component_has_children(island)) else NIL), (lambda body_html: (lambda state_json: sx_str('<div data-sx-island="', escape_attr(island_name), '"', (sx_str(' data-sx-state="', escape_attr(state_json), '"') if sx_truthy(state_json) else ''), '>', body_html, '</div>'))(serialize_island_state(kwargs)))(render_to_html(component_body(island), local))))(component_name(island)))(env_merge(component_closure(island), env))))([]))({})
# serialize-island-state
serialize_island_state = lambda kwargs: (NIL if sx_truthy(is_empty_dict(kwargs)) else json_serialize(kwargs))
# === Transpiled from adapter-sx ===
@@ -1224,7 +1307,7 @@ render_to_sx = lambda expr, env: (lambda result: (result if sx_truthy((type_of(r
aser = lambda expr, env: _sx_case(type_of(expr), [('number', lambda: expr), ('string', lambda: expr), ('boolean', lambda: expr), ('nil', lambda: NIL), ('symbol', lambda: (lambda name: (env_get(env, name) if sx_truthy(env_has(env, name)) else (get_primitive(name) if sx_truthy(is_primitive(name)) else (True if sx_truthy((name == 'true')) else (False if sx_truthy((name == 'false')) else (NIL if sx_truthy((name == 'nil')) else error(sx_str('Undefined symbol: ', name))))))))(symbol_name(expr))), ('keyword', lambda: keyword_name(expr)), ('list', lambda: ([] if sx_truthy(empty_p(expr)) else aser_list(expr, env))), (None, lambda: expr)])
# aser-list
aser_list = lambda expr, env: (lambda head: (lambda args: (map(lambda x: aser(x, env), expr) if sx_truthy((not sx_truthy((type_of(head) == 'symbol')))) else (lambda name: (aser_fragment(args, env) if sx_truthy((name == '<>')) else (aser_call(name, args, env) if sx_truthy(starts_with_p(name, '~')) else (aser_call(name, args, env) if sx_truthy(contains_p(HTML_TAGS, name)) else (aser_special(name, expr, env) if sx_truthy((is_special_form(name) if sx_truthy(is_special_form(name)) else is_ho_form(name))) else (aser(expand_macro(env_get(env, name), args, env), env) if sx_truthy((env_has(env, name) if not sx_truthy(env_has(env, name)) else is_macro(env_get(env, name)))) else (lambda f: (lambda evaled_args: (apply(f, evaled_args) if sx_truthy((is_callable(f) if not sx_truthy(is_callable(f)) else ((not sx_truthy(is_lambda(f))) if not sx_truthy((not sx_truthy(is_lambda(f)))) else (not sx_truthy(is_component(f)))))) else (trampoline(call_lambda(f, evaled_args, env)) if sx_truthy(is_lambda(f)) else (aser_call(sx_str('~', component_name(f)), args, env) if sx_truthy(is_component(f)) else error(sx_str('Not callable: ', inspect(f)))))))(map(lambda a: trampoline(eval_expr(a, env)), args)))(trampoline(eval_expr(head, env)))))))))(symbol_name(head))))(rest(expr)))(first(expr))
aser_list = lambda expr, env: (lambda head: (lambda args: (map(lambda x: aser(x, env), expr) if sx_truthy((not sx_truthy((type_of(head) == 'symbol')))) else (lambda name: (aser_fragment(args, env) if sx_truthy((name == '<>')) else (aser_call(name, args, env) if sx_truthy(starts_with_p(name, '~')) else (aser_call(name, args, env) if sx_truthy(contains_p(HTML_TAGS, name)) else (aser_special(name, expr, env) if sx_truthy((is_special_form(name) if sx_truthy(is_special_form(name)) else is_ho_form(name))) else (aser(expand_macro(env_get(env, name), args, env), env) if sx_truthy((env_has(env, name) if not sx_truthy(env_has(env, name)) else is_macro(env_get(env, name)))) else (lambda f: (lambda evaled_args: (apply(f, evaled_args) if sx_truthy((is_callable(f) if not sx_truthy(is_callable(f)) else ((not sx_truthy(is_lambda(f))) if not sx_truthy((not sx_truthy(is_lambda(f)))) else ((not sx_truthy(is_component(f))) if not sx_truthy((not sx_truthy(is_component(f)))) else (not sx_truthy(is_island(f))))))) else (trampoline(call_lambda(f, evaled_args, env)) if sx_truthy(is_lambda(f)) else (aser_call(sx_str('~', component_name(f)), args, env) if sx_truthy(is_component(f)) else (aser_call(sx_str('~', component_name(f)), args, env) if sx_truthy(is_island(f)) else error(sx_str('Not callable: ', inspect(f))))))))(map(lambda a: trampoline(eval_expr(a, env)), args)))(trampoline(eval_expr(head, env)))))))))(symbol_name(head))))(rest(expr)))(first(expr))
# aser-fragment
aser_fragment = lambda children, env: (lambda parts: ('' if sx_truthy(empty_p(parts)) else sx_str('(<> ', join(' ', map(serialize, parts)), ')')))(filter(lambda x: (not sx_truthy(is_nil(x))), map(lambda c: aser(c, env), children)))
@@ -1233,61 +1316,77 @@ aser_fragment = lambda children, env: (lambda parts: ('' if sx_truthy(empty_p(pa
aser_call = lambda name, args, env: (lambda parts: _sx_begin(reduce(lambda state, arg: (lambda skip: (assoc(state, 'skip', False, 'i', (get(state, 'i') + 1)) if sx_truthy(skip) else ((lambda val: _sx_begin((_sx_begin(_sx_append(parts, sx_str(':', keyword_name(arg))), _sx_append(parts, serialize(val))) if sx_truthy((not sx_truthy(is_nil(val)))) else NIL), assoc(state, 'skip', True, 'i', (get(state, 'i') + 1))))(aser(nth(args, (get(state, 'i') + 1)), env)) if sx_truthy(((type_of(arg) == 'keyword') if not sx_truthy((type_of(arg) == 'keyword')) else ((get(state, 'i') + 1) < len(args)))) else (lambda val: _sx_begin((_sx_append(parts, serialize(val)) if sx_truthy((not sx_truthy(is_nil(val)))) else NIL), assoc(state, 'i', (get(state, 'i') + 1))))(aser(arg, env)))))(get(state, 'skip')), {'i': 0, 'skip': False}, args), sx_str('(', join(' ', parts), ')')))([name])
# === Transpiled from deps (component dependency analysis) ===
# === Transpiled from signals (reactive signal runtime) ===
# scan-refs
scan_refs = lambda node: (lambda refs: _sx_begin(scan_refs_walk(node, refs), refs))([])
# signal
signal = lambda initial_value: make_signal(initial_value)
# scan-refs-walk
scan_refs_walk = lambda node, refs: ((lambda name: ((_sx_append(refs, name) if sx_truthy((not sx_truthy(contains_p(refs, name)))) else NIL) if sx_truthy(starts_with_p(name, '~')) else NIL))(symbol_name(node)) if sx_truthy((type_of(node) == 'symbol')) else (for_each(lambda item: scan_refs_walk(item, refs), node) if sx_truthy((type_of(node) == 'list')) else (for_each(lambda key: scan_refs_walk(dict_get(node, key), refs), keys(node)) if sx_truthy((type_of(node) == 'dict')) else NIL)))
# deref
deref = lambda s: (s if sx_truthy((not sx_truthy(is_signal(s)))) else (lambda ctx: _sx_begin((_sx_begin(tracking_context_add_dep(ctx, s), signal_add_sub(s, tracking_context_notify_fn(ctx))) if sx_truthy(ctx) else NIL), signal_value(s)))(get_tracking_context()))
# transitive-deps-walk
transitive_deps_walk = lambda n, seen, env: (_sx_begin(_sx_append(seen, n), (lambda val: (for_each(lambda ref: transitive_deps_walk(ref, seen, env), scan_refs(component_body(val))) if sx_truthy((type_of(val) == 'component')) else (for_each(lambda ref: transitive_deps_walk(ref, seen, env), scan_refs(macro_body(val))) if sx_truthy((type_of(val) == 'macro')) else NIL)))(env_get(env, n))) if sx_truthy((not sx_truthy(contains_p(seen, n)))) else NIL)
# reset!
reset_b = lambda s, value: ((lambda old: (_sx_begin(signal_set_value(s, value), notify_subscribers(s)) if sx_truthy((not sx_truthy(is_identical(old, value)))) else NIL))(signal_value(s)) if sx_truthy(is_signal(s)) else NIL)
# transitive-deps
transitive_deps = lambda name, env: (lambda seen: (lambda key: _sx_begin(transitive_deps_walk(key, seen, env), filter(lambda x: (not sx_truthy((x == key))), seen)))((name if sx_truthy(starts_with_p(name, '~')) else sx_str('~', name))))([])
# swap!
swap_b = lambda s, f, *args: ((lambda old: (lambda new_val: (_sx_begin(signal_set_value(s, new_val), notify_subscribers(s)) if sx_truthy((not sx_truthy(is_identical(old, new_val)))) else NIL))(apply(f, cons(old, args))))(signal_value(s)) if sx_truthy(is_signal(s)) else NIL)
# compute-all-deps
compute_all_deps = lambda env: for_each(lambda name: (lambda val: (component_set_deps(val, transitive_deps(name, env)) if sx_truthy((type_of(val) == 'component')) else NIL))(env_get(env, name)), env_components(env))
# computed
computed = lambda compute_fn: (lambda s: (lambda deps: (lambda compute_ctx: (lambda recompute: _sx_begin(recompute(), s))(_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))))(compute_fn())))(get_tracking_context()))(make_tracking_context(recompute))
)[-1])))(NIL))([]))(make_signal(NIL))
# scan-components-from-source
scan_components_from_source = lambda source: (lambda matches: map(lambda m: sx_str('~', m), matches))(regex_find_all('\\(~([a-zA-Z_][a-zA-Z0-9_\\-]*)', source))
# effect
def effect(effect_fn):
_cells = {}
_cells['deps'] = []
_cells['disposed'] = False
_cells['cleanup_fn'] = NIL
run_effect = lambda : (_sx_begin((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)))(effect_fn())))(get_tracking_context()))(make_tracking_context(run_effect))) if sx_truthy((not sx_truthy(_cells['disposed']))) else NIL)
run_effect()
return _sx_fn(lambda : (
_sx_cell_set(_cells, 'disposed', True),
(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', [])
)[-1])
# components-needed
components_needed = lambda page_source, env: (lambda direct: (lambda all_needed: _sx_begin(for_each(_sx_fn(lambda name: (
(_sx_append(all_needed, name) if sx_truthy((not sx_truthy(contains_p(all_needed, name)))) else NIL),
(lambda val: (lambda deps: for_each(lambda dep: (_sx_append(all_needed, dep) if sx_truthy((not sx_truthy(contains_p(all_needed, dep)))) else NIL), deps))((component_deps(val) if sx_truthy(((type_of(val) == 'component') if not sx_truthy((type_of(val) == 'component')) else (not sx_truthy(empty_p(component_deps(val)))))) else transitive_deps(name, env))))(env_get(env, name))
)[-1]), direct), all_needed))([]))(scan_components_from_source(page_source))
# *batch-depth*
_batch_depth = 0
# page-component-bundle
page_component_bundle = lambda page_source, env: components_needed(page_source, env)
# *batch-queue*
_batch_queue = []
# page-css-classes
page_css_classes = lambda page_source, env: (lambda needed: (lambda classes: _sx_begin(for_each(lambda name: (lambda val: (for_each(lambda cls: (_sx_append(classes, cls) if sx_truthy((not sx_truthy(contains_p(classes, cls)))) else NIL), component_css_classes(val)) if sx_truthy((type_of(val) == 'component')) else NIL))(env_get(env, name)), needed), for_each(lambda cls: (_sx_append(classes, cls) if sx_truthy((not sx_truthy(contains_p(classes, cls)))) else NIL), scan_css_classes(page_source)), classes))([]))(components_needed(page_source, env))
# batch
def batch(thunk):
_batch_depth = (_batch_depth + 1)
thunk()
_batch_depth = (_batch_depth - 1)
return ((lambda queue: _sx_begin(_sx_cell_set(_cells, '_batch_queue', []), (lambda seen: (lambda pending: _sx_begin(for_each(lambda s: for_each(lambda sub: (_sx_begin(_sx_append(seen, sub), _sx_append(pending, sub)) if sx_truthy((not sx_truthy(contains_p(seen, sub)))) else NIL), signal_subscribers(s)), queue), for_each(lambda sub: sub(), pending)))([]))([])))(_batch_queue) if sx_truthy((_batch_depth == 0)) else NIL)
# scan-io-refs-walk
scan_io_refs_walk = lambda node, io_names, refs: ((lambda name: ((_sx_append(refs, name) if sx_truthy((not sx_truthy(contains_p(refs, name)))) else NIL) if sx_truthy(contains_p(io_names, name)) else NIL))(symbol_name(node)) if sx_truthy((type_of(node) == 'symbol')) else (for_each(lambda item: scan_io_refs_walk(item, io_names, refs), node) if sx_truthy((type_of(node) == 'list')) else (for_each(lambda key: scan_io_refs_walk(dict_get(node, key), io_names, refs), keys(node)) if sx_truthy((type_of(node) == 'dict')) else NIL)))
# notify-subscribers
notify_subscribers = lambda s: ((_sx_append(_batch_queue, s) if sx_truthy((not sx_truthy(contains_p(_batch_queue, s)))) else NIL) if sx_truthy((_batch_depth > 0)) else flush_subscribers(s))
# scan-io-refs
scan_io_refs = lambda node, io_names: (lambda refs: _sx_begin(scan_io_refs_walk(node, io_names, refs), refs))([])
# flush-subscribers
flush_subscribers = lambda s: for_each(lambda sub: sub(), signal_subscribers(s))
# transitive-io-refs-walk
transitive_io_refs_walk = lambda n, seen, all_refs, env, io_names: (_sx_begin(_sx_append(seen, n), (lambda val: (_sx_begin(for_each(lambda ref: (_sx_append(all_refs, ref) if sx_truthy((not sx_truthy(contains_p(all_refs, ref)))) else NIL), scan_io_refs(component_body(val), io_names)), for_each(lambda dep: transitive_io_refs_walk(dep, seen, all_refs, env, io_names), scan_refs(component_body(val)))) if sx_truthy((type_of(val) == 'component')) else (_sx_begin(for_each(lambda ref: (_sx_append(all_refs, ref) if sx_truthy((not sx_truthy(contains_p(all_refs, ref)))) else NIL), scan_io_refs(macro_body(val), io_names)), for_each(lambda dep: transitive_io_refs_walk(dep, seen, all_refs, env, io_names), scan_refs(macro_body(val)))) if sx_truthy((type_of(val) == 'macro')) else NIL)))(env_get(env, n))) if sx_truthy((not sx_truthy(contains_p(seen, n)))) else NIL)
# dispose-computed
dispose_computed = lambda s: (_sx_begin(for_each(lambda dep: signal_remove_sub(dep, NIL), signal_deps(s)), signal_set_deps(s, [])) if sx_truthy(is_signal(s)) else NIL)
# transitive-io-refs
transitive_io_refs = lambda name, env, io_names: (lambda all_refs: (lambda seen: (lambda key: _sx_begin(transitive_io_refs_walk(key, seen, all_refs, env, io_names), all_refs))((name if sx_truthy(starts_with_p(name, '~')) else sx_str('~', name))))([]))([])
# *island-scope*
_island_scope = NIL
# compute-all-io-refs
compute_all_io_refs = lambda env, io_names: for_each(lambda name: (lambda val: (component_set_io_refs(val, transitive_io_refs(name, env, io_names)) if sx_truthy((type_of(val) == 'component')) else NIL))(env_get(env, name)), env_components(env))
# with-island-scope
def with_island_scope(scope_fn, body_fn):
prev = _island_scope
_island_scope = scope_fn
result = body_fn()
_island_scope = prev
return result
# component-pure?
component_pure_p = lambda name, env, io_names: empty_p(transitive_io_refs(name, env, io_names))
# render-target
render_target = lambda name, env, io_names: (lambda key: (lambda val: ('server' if sx_truthy((not sx_truthy((type_of(val) == 'component')))) else (lambda affinity: ('server' if sx_truthy((affinity == 'server')) else ('client' if sx_truthy((affinity == 'client')) else ('server' if sx_truthy((not sx_truthy(component_pure_p(name, env, io_names)))) else 'client'))))(component_affinity(val))))(env_get(env, key)))((name if sx_truthy(starts_with_p(name, '~')) else sx_str('~', name)))
# page-render-plan
page_render_plan = lambda page_source, env, io_names: (lambda needed: (lambda comp_targets: (lambda server_list: (lambda client_list: (lambda io_deps: _sx_begin(for_each(lambda name: (lambda target: _sx_begin(_sx_dict_set(comp_targets, name, target), (_sx_begin(_sx_append(server_list, name), for_each(lambda io_ref: (_sx_append(io_deps, io_ref) if sx_truthy((not sx_truthy(contains_p(io_deps, io_ref)))) else NIL), transitive_io_refs(name, env, io_names))) if sx_truthy((target == 'server')) else _sx_append(client_list, name))))(render_target(name, env, io_names)), needed), {'components': comp_targets, 'server': server_list, 'client': client_list, 'io-deps': io_deps}))([]))([]))([]))({}))(components_needed(page_source, env))
# register-in-scope
register_in_scope = lambda disposable: (_island_scope(disposable) if sx_truthy(_island_scope) else NIL)
# =========================================================================

View 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"))))

View File

@@ -21,7 +21,7 @@ sys.path.insert(0, _PROJECT)
from shared.sx.parser import parse_all
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 ---
suite_stack: list[str] = []
@@ -134,16 +134,134 @@ def render_html(sx_source):
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 ---
SPECS = {
"eval": {"file": "test-eval.sx", "needs": []},
"parser": {"file": "test-parser.sx", "needs": ["sx-parse"]},
"router": {"file": "test-router.sx", "needs": []},
"render": {"file": "test-render.sx", "needs": ["render-html"]},
"deps": {"file": "test-deps.sx", "needs": []},
"engine": {"file": "test-engine.sx", "needs": []},
"eval": {"file": "test-eval.sx", "needs": []},
"parser": {"file": "test-parser.sx", "needs": ["sx-parse"]},
"router": {"file": "test-router.sx", "needs": []},
"render": {"file": "test-render.sx", "needs": ["render-html"]},
"deps": {"file": "test-deps.sx", "needs": []},
"engine": {"file": "test-engine.sx", "needs": []},
"orchestration": {"file": "test-orchestration.sx", "needs": []},
"signals": {"file": "test-signals.sx", "needs": ["make-signal"]},
}
REF_DIR = os.path.join(_HERE, "..", "ref")
@@ -187,6 +305,31 @@ env = {
"inc": lambda n: n + 1,
# Component accessor for affinity (Phase 7)
"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),
}
@@ -412,6 +555,171 @@ def _load_forms_from_bootstrap(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():
global passed, failed, test_num
@@ -457,6 +765,8 @@ def main():
_load_engine_from_bootstrap(env)
if spec_name == "orchestration":
_load_orchestration(env)
if spec_name == "signals":
_load_signals(env)
print(f"# --- {spec_name} ---")
eval_file(spec["file"], env)

View File

@@ -189,6 +189,31 @@ class Component:
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
# ---------------------------------------------------------------------------
@@ -355,4 +380,4 @@ class _ShiftSignal(BaseException):
# ---------------------------------------------------------------------------
# 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