Wire reactive islands end-to-end: live interactive demos on the demo page

- Rebuild sx-browser.js with signals spec module (was missing entirely)
- Register signal functions (signal, deref, effect, computed, etc.) as
  PRIMITIVES so runtime-evaluated SX code in island bodies can call them
- Add reactive deref detection in adapter-dom.sx: (deref sig) in island
  scope creates reactive-text node instead of static text
- Add Island SSR support in html.py (_render_island with data-sx-island)
- Add Island bundling in jinja_bridge.py (defisland defs sent to client)
- Update deps.py to track Island dependencies alongside Component
- Add defisland to _ASER_FORMS in async_eval.py
- Add clear-interval platform primitive (was missing)
- Create four live demo islands: counter, temperature, imperative, stopwatch

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 11:57:58 +00:00
parent 50a184faf2
commit 9a0173419a
9 changed files with 855 additions and 220 deletions

View File

@@ -1703,6 +1703,7 @@ _ASER_FORMS: dict[str, Any] = {
"defcomp": _assf_define,
"defmacro": _assf_define,
"defhandler": _assf_define,
"defisland": _assf_define,
"begin": _assf_begin,
"do": _assf_begin,
"quote": _assf_quote,

View File

@@ -10,7 +10,7 @@ from __future__ import annotations
import os
from typing import Any
from .types import Component, Macro, Symbol
from .types import Component, Island, Macro, Symbol
def _use_ref() -> bool:
@@ -50,7 +50,7 @@ def _transitive_deps_fallback(name: str, env: dict[str, Any]) -> set[str]:
return
seen.add(n)
val = env.get(n)
if isinstance(val, Component):
if isinstance(val, (Component, Island)):
for dep in _scan_ast(val.body):
walk(dep)
elif isinstance(val, Macro):
@@ -64,7 +64,7 @@ def _transitive_deps_fallback(name: str, env: dict[str, Any]) -> set[str]:
def _compute_all_deps_fallback(env: dict[str, Any]) -> None:
for key, val in env.items():
if isinstance(val, Component):
if isinstance(val, (Component, Island)):
val.deps = _transitive_deps_fallback(key, env)
@@ -102,7 +102,7 @@ def _transitive_io_refs_fallback(
return
seen.add(n)
val = env.get(n)
if isinstance(val, Component):
if isinstance(val, (Component, Island)):
all_refs.update(_scan_io_refs_fallback(val.body, io_names))
for dep in _scan_ast(val.body):
walk(dep)
@@ -120,7 +120,7 @@ def _compute_all_io_refs_fallback(
env: dict[str, Any], io_names: set[str]
) -> None:
for key, val in env.items():
if isinstance(val, Component):
if isinstance(val, (Component, Island)):
val.io_refs = _transitive_io_refs_fallback(key, env, io_names)
@@ -135,7 +135,7 @@ def _components_needed_fallback(page_sx: str, env: dict[str, Any]) -> set[str]:
for name in direct:
all_needed.add(name)
val = env.get(name)
if isinstance(val, Component) and val.deps:
if isinstance(val, (Component, Island)) and val.deps:
all_needed.update(val.deps)
else:
all_needed.update(_transitive_deps_fallback(name, env))

View File

@@ -27,7 +27,7 @@ from __future__ import annotations
import contextvars
from typing import Any
from .types import Component, Keyword, Lambda, Macro, NIL, Symbol
from .types import Component, Island, Keyword, Lambda, Macro, NIL, Symbol
from .evaluator import _eval as _raw_eval, _call_component as _raw_call_component, _expand_macro, _trampoline
def _eval(expr, env):
@@ -411,6 +411,64 @@ def _render_component(comp: Component, args: list, env: dict[str, Any]) -> str:
return _render(comp.body, local)
def _render_island(island: Island, args: list, env: dict[str, Any]) -> str:
"""Render an island as static HTML with hydration attributes.
Produces: <div data-sx-island="name" data-sx-state='{"k":"v",...}'>body HTML</div>
The client hydrates this into a reactive island.
"""
import json as _json
kwargs: dict[str, Any] = {}
children: list[Any] = []
i = 0
while i < len(args):
arg = args[i]
if isinstance(arg, Keyword) and i + 1 < len(args):
kwargs[arg.name] = _eval(args[i + 1], env)
i += 2
else:
children.append(arg)
i += 1
local = dict(island.closure)
local.update(env)
for p in island.params:
if p in kwargs:
local[p] = kwargs[p]
else:
local[p] = NIL
if island.has_children:
local["children"] = _RawHTML("".join(_render(c, env) for c in children))
body_html = _render(island.body, local)
# Serialize state for hydration — only keyword args
state = {}
for k, v in kwargs.items():
if isinstance(v, (str, int, float, bool)):
state[k] = v
elif v is NIL or v is None:
state[k] = None
elif isinstance(v, list):
state[k] = v
elif isinstance(v, dict):
state[k] = v
else:
state[k] = str(v)
state_json = _escape_attr(_json.dumps(state, separators=(",", ":"))) if state else ""
island_name = _escape_attr(island.name)
parts = [f'<div data-sx-island="{island_name}"']
if state_json:
parts.append(f' data-sx-state="{state_json}"')
parts.append(">")
parts.append(body_html)
parts.append("</div>")
return "".join(parts)
def _render_list(expr: list, env: dict[str, Any]) -> str:
"""Render a list expression — could be an HTML element, special form,
component call, or data list."""
@@ -464,9 +522,11 @@ def _render_list(expr: list, env: dict[str, Any]) -> str:
if name in HTML_TAGS:
return _render_element(name, expr[1:], env)
# --- Component (~prefix) → render-aware component call ------------
# --- Component/Island (~prefix) → render-aware call ----------------
if name.startswith("~"):
val = env.get(name)
if isinstance(val, Island):
return _render_island(val, expr[1:], env)
if isinstance(val, Component):
return _render_component(val, expr[1:], env)
# Fall through to evaluation

View File

@@ -25,7 +25,7 @@ import hashlib
import os
from typing import Any
from .types import NIL, Component, Keyword, Macro, Symbol
from .types import NIL, Component, Island, Keyword, Macro, Symbol
from .parser import parse
import os as _os
if _os.environ.get("SX_USE_REF") == "1":
@@ -64,7 +64,14 @@ def _compute_component_hash() -> None:
parts = []
for key in sorted(_COMPONENT_ENV):
val = _COMPONENT_ENV[key]
if isinstance(val, Component):
if isinstance(val, Island):
param_strs = ["&key"] + list(val.params)
if val.has_children:
param_strs.extend(["&rest", "children"])
params_sx = "(" + " ".join(param_strs) + ")"
body_sx = serialize(val.body)
parts.append(f"(defisland ~{val.name} {params_sx} {body_sx})")
elif isinstance(val, Component):
param_strs = ["&key"] + list(val.params)
if val.has_children:
param_strs.extend(["&rest", "children"])
@@ -198,7 +205,7 @@ def register_components(sx_source: str) -> None:
# Slightly over-counts per component but safe and avoids re-scanning at request time.
all_classes: set[str] | None = None
for key, val in _COMPONENT_ENV.items():
if key not in existing and isinstance(val, Component):
if key not in existing and isinstance(val, (Component, Island)):
if all_classes is None:
all_classes = scan_classes_from_sx(sx_source)
val.css_classes = set(all_classes)
@@ -307,7 +314,16 @@ def client_components_tag(*names: str) -> str:
from .parser import serialize
parts = []
for key, val in _COMPONENT_ENV.items():
if isinstance(val, Component):
if isinstance(val, Island):
if names and val.name not in names and key.lstrip("~") not in names:
continue
param_strs = ["&key"] + list(val.params)
if val.has_children:
param_strs.extend(["&rest", "children"])
params_sx = "(" + " ".join(param_strs) + ")"
body_sx = serialize(val.body, pretty=True)
parts.append(f"(defisland ~{val.name} {params_sx} {body_sx})")
elif isinstance(val, Component):
if names and val.name not in names and key.lstrip("~") not in names:
continue
# Reconstruct defcomp source from the Component object
@@ -365,7 +381,15 @@ def components_for_page(page_sx: str, service: str | None = None) -> tuple[str,
# Also include macros — they're needed for client-side expansion
parts = []
for key, val in _COMPONENT_ENV.items():
if isinstance(val, Component):
if isinstance(val, Island):
if f"~{val.name}" in needed or key in needed:
param_strs = ["&key"] + list(val.params)
if val.has_children:
param_strs.extend(["&rest", "children"])
params_sx = "(" + " ".join(param_strs) + ")"
body_sx = serialize(val.body, pretty=True)
parts.append(f"(defisland ~{val.name} {params_sx} {body_sx})")
elif isinstance(val, Component):
if f"~{val.name}" in needed or key in needed:
param_strs = ["&key"] + list(val.params)
if val.has_children:
@@ -412,7 +436,7 @@ def css_classes_for_page(page_sx: str, service: str | None = None) -> set[str]:
classes: set[str] = set()
for key, val in _COMPONENT_ENV.items():
if isinstance(val, Component):
if isinstance(val, (Component, Island)):
if (f"~{val.name}" in needed or key in needed) and val.css_classes:
classes.update(val.css_classes)

View File

@@ -125,6 +125,13 @@
ns
(render-dom-element name args env ns)
;; deref in island scope → reactive text node
(and (= name "deref") *island-scope*)
(let ((sig-or-val (trampoline (eval-expr (first args) env))))
(if (signal? sig-or-val)
(reactive-text sig-or-val)
(create-text-node (str (deref sig-or-val)))))
;; Fallback — evaluate then render
:else
(render-to-dom (trampoline (eval-expr expr env)) env ns)))

View File

@@ -416,6 +416,7 @@ class JSEmitter:
"set-timeout": "setTimeout_",
"set-interval": "setInterval_",
"clear-timeout": "clearTimeout_",
"clear-interval": "clearInterval_",
"request-animation-frame": "requestAnimationFrame_",
"csrf-token": "csrfToken",
"cross-origin?": "isCrossOrigin",
@@ -2003,7 +2004,7 @@ def compile_ref_to_js(
if name in adapter_set and name in adapter_platform:
parts.append(adapter_platform[name])
parts.append(fixups_js(has_html, has_sx, has_dom))
parts.append(fixups_js(has_html, has_sx, has_dom, has_signals))
if has_continuations:
parts.append(CONTINUATIONS_JS)
if has_dom:
@@ -3126,6 +3127,7 @@ PLATFORM_ORCHESTRATION_JS = """
function setTimeout_(fn, ms) { return setTimeout(fn, ms || 0); }
function setInterval_(fn, ms) { return setInterval(fn, ms || 1000); }
function clearTimeout_(id) { clearTimeout(id); }
function clearInterval_(id) { clearInterval(id); }
function requestAnimationFrame_(fn) {
if (typeof requestAnimationFrame !== "undefined") requestAnimationFrame(fn);
else setTimeout(fn, 16);
@@ -4023,7 +4025,7 @@ PLATFORM_BOOT_JS = """
"""
def fixups_js(has_html, has_sx, has_dom):
def fixups_js(has_html, has_sx, has_dom, has_signals=False):
lines = ['''
// =========================================================================
// Post-transpilation fixups
@@ -4044,6 +4046,31 @@ def fixups_js(has_html, has_sx, has_dom):
lines.append(' if (typeof aser === "function") PRIMITIVES["aser"] = aser;')
if has_dom:
lines.append(' if (typeof renderToDom === "function") PRIMITIVES["render-to-dom"] = renderToDom;')
if has_signals:
lines.append('''
// Expose signal functions as primitives so runtime-evaluated SX code
// (e.g. island bodies from .sx files) can call them
PRIMITIVES["signal"] = createSignal;
PRIMITIVES["signal?"] = isSignal;
PRIMITIVES["deref"] = deref;
PRIMITIVES["reset!"] = reset_b;
PRIMITIVES["swap!"] = swap_b;
PRIMITIVES["computed"] = computed;
PRIMITIVES["effect"] = effect;
PRIMITIVES["batch"] = batch;
PRIMITIVES["dispose"] = dispose;
// Reactive DOM helpers for island code
PRIMITIVES["reactive-text"] = reactiveText;
PRIMITIVES["create-text-node"] = createTextNode;
PRIMITIVES["dom-set-text-content"] = domSetTextContent;
PRIMITIVES["dom-listen"] = domListen;
PRIMITIVES["dom-dispatch"] = domDispatch;
PRIMITIVES["event-detail"] = eventDetail;
PRIMITIVES["def-store"] = defStore;
PRIMITIVES["use-store"] = useStore;
PRIMITIVES["emit-event"] = emitEvent;
PRIMITIVES["on-event"] = onEvent;
PRIMITIVES["bridge-event"] = bridgeEvent;''')
return "\n".join(lines)