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:
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user