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

@@ -127,6 +127,100 @@
(td :class "px-3 py-2 font-mono text-xs text-stone-500" "reactive-list morph"))))))))
;; ---------------------------------------------------------------------------
;; Live demo islands
;; ---------------------------------------------------------------------------
;; 1. Counter — basic signal + effect
(defisland ~demo-counter (&key initial)
(let ((count (signal (or initial 0)))
(doubled (computed (fn () (* 2 (deref count))))))
(div :class "rounded border border-violet-200 bg-violet-50 p-4 my-4"
(div :class "flex items-center gap-4"
(button :class "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700"
:on-click (fn (e) (swap! count dec))
"")
(span :class "text-2xl font-bold text-violet-900 w-12 text-center"
(deref count))
(button :class "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700"
:on-click (fn (e) (swap! count inc))
"+"))
(p :class "text-sm text-stone-500 mt-2"
"doubled: " (span :class "font-mono text-violet-700" (deref doubled))))))
;; 2. Temperature converter — computed derived signal
(defisland ~demo-temperature ()
(let ((celsius (signal 20))
(fahrenheit (computed (fn () (+ (* (deref celsius) 1.8) 32)))))
(div :class "rounded border border-violet-200 bg-violet-50 p-4 my-4"
(div :class "flex items-center gap-3"
(div :class "flex items-center gap-2"
(button :class "px-2 py-1 rounded bg-stone-200 text-stone-700 text-sm hover:bg-stone-300"
:on-click (fn (e) (swap! celsius (fn (c) (- c 5))))
"5")
(span :class "font-mono text-lg font-bold text-violet-900 w-16 text-center"
(deref celsius))
(button :class "px-2 py-1 rounded bg-stone-200 text-stone-700 text-sm hover:bg-stone-300"
:on-click (fn (e) (swap! celsius (fn (c) (+ c 5))))
"+5")
(span :class "text-stone-500" "°C"))
(span :class "text-stone-400" "=")
(span :class "font-mono text-lg font-bold text-violet-900"
(deref fahrenheit))
(span :class "text-stone-500" "°F")))))
;; 3. Imperative counter — shows create-text-node + effect pattern
(defisland ~demo-imperative ()
(let ((count (signal 0))
(text-node (create-text-node "0"))
(_eff (effect (fn ()
(dom-set-text-content text-node (str (deref count)))))))
(div :class "rounded border border-stone-200 bg-stone-50 p-4 my-4"
(p :class "text-sm text-stone-600 mb-2" "Imperative style — explicit " (code "effect") " + " (code "create-text-node") ":")
(div :class "flex items-center gap-4"
(button :class "px-3 py-1 rounded bg-stone-600 text-white text-sm font-medium hover:bg-stone-700"
:on-click (fn (e) (swap! count dec))
"")
(span :class "text-2xl font-bold text-stone-900 w-12 text-center"
text-node)
(button :class "px-3 py-1 rounded bg-stone-600 text-white text-sm font-medium hover:bg-stone-700"
:on-click (fn (e) (swap! count inc))
"+")))))
;; 4. Stopwatch — effect with cleanup (interval), fully imperative
(defisland ~demo-stopwatch ()
(let ((running (signal false))
(elapsed (signal 0))
(time-text (create-text-node "0.0s"))
(btn-text (create-text-node "Start"))
;; Timer effect — creates/clears interval based on running signal
(_e1 (effect (fn ()
(when (deref running)
(let ((id (set-interval (fn () (swap! elapsed inc)) 100)))
(fn () (clear-interval id)))))))
;; Display effect
(_e2 (effect (fn ()
(let ((e (deref elapsed)))
(dom-set-text-content time-text
(str (floor (/ e 10)) "." (mod e 10) "s"))))))
;; Button label effect
(_e3 (effect (fn ()
(dom-set-text-content btn-text
(if (deref running) "Stop" "Start"))))))
(div :class "rounded border border-violet-200 bg-violet-50 p-4 my-4"
(div :class "flex items-center gap-4"
(span :class "font-mono text-2xl font-bold text-violet-900 w-24 text-center"
time-text)
(button :class "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700"
:on-click (fn (e) (swap! running not))
btn-text)
(button :class "px-3 py-1 rounded bg-stone-300 text-stone-700 text-sm hover:bg-stone-400"
:on-click (fn (e)
(reset! running false)
(reset! elapsed 0))
"Reset")))))
;; ---------------------------------------------------------------------------
;; Demo page — shows what's been implemented
;; ---------------------------------------------------------------------------
@@ -135,33 +229,37 @@
(~doc-page :title "Reactive Islands Demo"
(~doc-section :title "What this demonstrates" :id "what"
(p "Everything below runs on signal primitives " (strong "transpiled from the SX spec") ". The signal runtime is defined in " (code "signals.sx") " (291 lines of s-expressions), then bootstrapped to JavaScript by " (code "bootstrap_js.py") ". No hand-written signal logic in JavaScript.")
(p "The transpiled " (code "sx-ref.js") " exports " (code "Sx.signal") ", " (code "Sx.deref") ", " (code "Sx.reset") ", " (code "Sx.swap") ", " (code "Sx.computed") ", " (code "Sx.effect") ", and " (code "Sx.batch") " — all generated from the spec."))
(p (strong "These are live interactive islands") " — not static code snippets. Click the buttons. The signal runtime is defined in " (code "signals.sx") " (374 lines of s-expressions), then bootstrapped to JavaScript by " (code "bootstrap_js.py") ". No hand-written signal logic in JavaScript.")
(p "The transpiled " (code "sx-browser.js") " registers " (code "signal") ", " (code "deref") ", " (code "reset!") ", " (code "swap!") ", " (code "computed") ", " (code "effect") ", and " (code "batch") " as SX primitivescallable from " (code "defisland") " bodies defined in " (code ".sx") " files."))
(~doc-section :title "1. Signal + Computed + Effect" :id "demo-counter"
(p "A signal holds a value. A computed derives from it. Effects subscribe to both and update the DOM when either changes.")
(~doc-code :code (highlight "(define count (signal 0))\n(define doubled (computed (fn () (* 2 (deref count)))))\n\n;; Effect subscribes to count, updates DOM\n(effect (fn ()\n (dom-set-text-content display (deref count))))\n\n;; Effect subscribes to doubled, updates DOM\n(effect (fn ()\n (dom-set-text-content doubled-display (str \"doubled: \" (deref doubled)))))\n\n;; swap! updates count, both effects re-run\n(swap! count inc) ;; display shows 1, doubled-display shows \"doubled: 2\"" "lisp"))
(p "The counter increments. The doubled value updates automatically. Each effect only re-runs when its specific dependencies change. No virtual DOM. No diffing."))
(p "A signal holds a value. A computed derives from it. Click the buttons — the counter and doubled value update instantly, no server round-trip.")
(~demo-counter :initial 0)
(~doc-code :code (highlight "(defisland ~demo-counter (&key initial)\n (let ((count (signal (or initial 0)))\n (doubled (computed (fn () (* 2 (deref count))))))\n (div :class \"...\"\n (button :on-click (fn (e) (swap! count dec)) \"\")\n (span (deref count))\n (button :on-click (fn (e) (swap! count inc)) \"+\")\n (p \"doubled: \" (deref doubled)))))" "lisp"))
(p (code "(deref count)") " in a text position creates a reactive text node. When " (code "count") " changes, " (em "only that text node") " updates. " (code "doubled") " recomputes automatically. No diffing."))
(~doc-section :title "2. Batch" :id "demo-batch"
(p "Without batch, two signal writes trigger two effect runs. With batch, writes are deferred and subscribers notified once at the end.")
(~doc-code :code (highlight ";; Without batch: 2 writes = 2 effect runs\n(reset! first 1) ;; effect runs\n(reset! second 2) ;; effect runs again\n\n;; With batch: 2 writes = 1 effect run\n(batch (fn ()\n (reset! first 1)\n (reset! second 2))) ;; effect runs once" "lisp"))
(p "Batch deduplicates subscribers across all queued signals. If two signals notify the same effect, it runs once, not twice."))
(~doc-section :title "2. Temperature Converter" :id "demo-temperature"
(p "Two derived values from one signal. Click to change Celsius — Fahrenheit updates reactively.")
(~demo-temperature)
(~doc-code :code (highlight "(defisland ~demo-temperature ()\n (let ((celsius (signal 20)))\n (div :class \"...\"\n (button :on-click (fn (e) (swap! celsius (fn (c) (- c 5)))) \"5\")\n (span (deref celsius))\n (button :on-click (fn (e) (swap! celsius (fn (c) (+ c 5)))) \"+5\")\n (span \"°C = \")\n (span (+ (* (deref celsius) 1.8) 32))\n (span \"°F\"))))" "lisp"))
(p "The actual implementation uses " (code "computed") " for Fahrenheit: " (code "(computed (fn () (+ (* (deref celsius) 1.8) 32)))") ". The " (code "(deref fahrenheit)") " in the span creates a reactive text node that updates when celsius changes."))
(~doc-section :title "3. Effect with cleanup" :id "demo-effect"
(p "An effect can return a cleanup function. The cleanup runs before the effect re-runs (when dependencies change) and when the effect is disposed.")
(~doc-code :code (highlight "(effect (fn ()\n (let ((active (deref polling)))\n (when active\n (let ((id (set-interval poll-fn 500)))\n ;; Return cleanup — runs before next re-run or on dispose\n (fn () (clear-interval id)))))))" "lisp"))
(p "This mirrors React's " (code "useEffect") " cleanup pattern, but without the hook rules. The effect can be created anywhere — in a conditional, in a loop, in a closure."))
(~doc-section :title "3. Effect + Cleanup: Stopwatch" :id "demo-stopwatch"
(p "Effects can return cleanup functions. This stopwatch starts a " (code "set-interval") " — the cleanup clears it when the running signal toggles off.")
(~demo-stopwatch)
(~doc-code :code (highlight "(defisland ~demo-stopwatch ()\n (let ((running (signal false))\n (elapsed (signal 0))\n (time-text (create-text-node \"0.0s\"))\n (btn-text (create-text-node \"Start\")))\n ;; Timer: effect creates interval, cleanup clears it\n (effect (fn ()\n (when (deref running)\n (let ((id (set-interval (fn () (swap! elapsed inc)) 100)))\n (fn () (clear-interval id))))))\n ;; Display: updates text node when elapsed changes\n (effect (fn ()\n (let ((e (deref elapsed)))\n (dom-set-text-content time-text\n (str (floor (/ e 10)) \".\" (mod e 10) \"s\")))))\n ;; Button label\n (effect (fn ()\n (dom-set-text-content btn-text\n (if (deref running) \"Stop\" \"Start\"))))\n (div :class \"...\"\n (span time-text)\n (button :on-click (fn (e) (swap! running not)) btn-text)\n (button :on-click (fn (e)\n (reset! running false) (reset! elapsed 0)) \"Reset\"))))" "lisp"))
(p "Three effects, each tracking different signals. The timer effect's cleanup fires before each re-run — toggling " (code "running") " off clears the interval. No hook rules: effects can appear anywhere, in any order."))
(~doc-section :title "4. Computed chains" :id "demo-chain"
(p "Computed signals can depend on other computed signals. The dependency graph builds itself via " (code "deref") " calls during evaluation.")
(~doc-code :code (highlight "(define base (signal 1))\n(define doubled (computed (fn () (* 2 (deref base)))))\n(define quadrupled (computed (fn () (* 2 (deref doubled)))))\n\n;; Change base, both derived signals update\n(reset! base 3)\n(deref quadrupled) ;; => 12" "lisp"))
(p "Three-level dependency chain. When " (code "base") " changes, " (code "doubled") " recomputes, which triggers " (code "quadrupled") " to recompute. Each computed only recomputes if its actual value changed — " (code "identical?") " check prevents unnecessary propagation."))
(~doc-section :title "4. Imperative Pattern" :id "demo-imperative"
(p "For complex reactivity (dynamic classes, conditional text), use the imperative pattern: " (code "create-text-node") " + " (code "effect") " + " (code "dom-set-text-content") ".")
(~demo-imperative)
(~doc-code :code (highlight "(defisland ~demo-imperative ()\n (let ((count (signal 0))\n (text-node (create-text-node \"0\")))\n ;; Explicit effect: re-runs when count changes\n (effect (fn ()\n (dom-set-text-content text-node (str (deref count)))))\n (div :class \"...\"\n (span text-node)\n (button :on-click (fn (e) (swap! count inc)) \"+\"))))" "lisp"))
(p "Two patterns exist: " (strong "declarative") " (" (code "(span (deref sig))") " — auto-reactive via " (code "reactive-text") ") and " (strong "imperative") " (" (code "create-text-node") " + " (code "effect") " — explicit, full control). Use declarative for simple text, imperative for dynamic classes, conditional DOM, or complex updates."))
(~doc-section :title "5. defisland" :id "demo-island"
(p (code "defisland") " creates a reactive component. Same calling convention as " (code "defcomp") " — keyword args, rest children — but with an island flag that triggers reactive rendering.")
(~doc-code :code (highlight "(defisland ~counter (&key initial)\n (let ((count (signal (or initial 0))))\n (div :class \"counter\"\n (span :class \"text-2xl font-bold\" (deref count))\n (div :class \"flex gap-2 mt-2\"\n (button :on-click (fn (e) (swap! count inc)) \"+\")\n (button :on-click (fn (e) (swap! count dec)) \"-\")))))\n\n;; Server renders static HTML:\n;; <div data-sx-island=\"counter\" data-sx-state='{\"initial\": 0}'>\n;; <span class=\"text-2xl font-bold\">0</span>\n;; <div class=\"flex gap-2 mt-2\">\n;; <button>+</button> <button>-</button>\n;; </div>\n;; </div>" "lisp"))
(p "The island is self-contained. " (code "count") " is local state. Buttons modify it. The span updates. Nothing outside the island is affected. No server round-trip."))
(~doc-section :title "5. How defisland Works" :id "demo-island"
(p (code "defisland") " creates a reactive component. Same calling convention as " (code "defcomp") " — keyword args, rest children — but with a reactive boundary. Inside an island, " (code "deref") " subscribes DOM nodes to signals.")
(~doc-code :code (highlight ";; Definition — same syntax as defcomp\n(defisland ~counter (&key initial)\n (let ((count (signal (or initial 0))))\n (div\n (span (deref count)) ;; reactive text node\n (button :on-click (fn (e) (swap! count inc)) ;; event handler\n \"+\"))))\n\n;; Usage — same as any component\n(~counter :initial 42)\n\n;; Server-side rendering:\n;; <div data-sx-island=\"counter\" data-sx-state='{\"initial\":42}'>\n;; <span>42</span><button>+</button>\n;; </div>\n;;\n;; Client hydrates: signals + effects + event handlers attach" "lisp"))
(p "Each " (code "deref") " call registers the enclosing DOM node as a subscriber. Signal changes update " (em "only") " the subscribed nodes — no virtual DOM, no diffing, no component re-renders."))
(~doc-section :title "6. Test suite" :id "demo-tests"
(p "17 tests verify the signal runtime against the spec. All pass in the Python test runner (which uses the hand-written evaluator with native platform primitives).")