Files
rose-ash/sx/sx/reactive-islands.sx
giles 7efd1b401b Add suspense, resource, and transitions — Phase 2 complete
- suspense render-dom form: shows fallback while resource loads, swaps
  to body content when resource signal resolves
- resource async signal: wraps promise into signal with loading/data/error
  dict, auto-transitions on resolve/reject via promise-then
- transition: defers signal writes to requestIdleCallback, sets pending
  signal for UI feedback during expensive operations
- Added schedule-idle, promise-then platform functions
- All Phase 2 features now marked Done in status tables

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 16:40:13 +00:00

899 lines
83 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
;; Reactive Islands section — top-level section for the reactive islands system.
;; ---------------------------------------------------------------------------
;; Index / Overview
;; ---------------------------------------------------------------------------
(defcomp ~reactive-islands-index-content ()
(~doc-page :title "Reactive Islands"
(~doc-section :title "Architecture" :id "architecture"
(p "Two orthogonal bars control how an SX page works:")
(ul :class "space-y-1 text-stone-600 list-disc pl-5"
(li (strong "Render boundary") " — where rendering happens (server HTML vs client DOM)")
(li (strong "State flow") " — how state flows (server state vs client signals)"))
(div :class "overflow-x-auto mt-4 mb-4"
(table :class "w-full text-sm text-left"
(thead
(tr :class "border-b border-stone-200"
(th :class "py-2 px-3 font-semibold text-stone-700" "")
(th :class "py-2 px-3 font-semibold text-stone-700" "Server State")
(th :class "py-2 px-3 font-semibold text-stone-700" "Client State")))
(tbody :class "text-stone-600"
(tr :class "border-b border-stone-100"
(td :class "py-2 px-3 font-semibold text-stone-700" "Server Rendering")
(td :class "py-2 px-3" "Pure hypermedia (htmx)")
(td :class "py-2 px-3" "SSR + hydrated islands"))
(tr :class "border-b border-stone-100"
(td :class "py-2 px-3 font-semibold text-stone-700" "Client Rendering")
(td :class "py-2 px-3" "SX wire format (current)")
(td :class "py-2 px-3 font-semibold text-violet-700" "Reactive islands (this)")))))
(p "Most content stays pure hypermedia. Interactive regions opt into reactivity. The author controls where each component sits on both bars."))
(~doc-section :title "Four Levels" :id "levels"
(div :class "space-y-4"
(div :class "rounded border border-stone-200 p-4"
(div :class "font-semibold text-stone-800" "Level 0: Pure Hypermedia")
(p :class "text-sm text-stone-600 mt-1"
"The default. " (code "sx-get") ", " (code "sx-post") ", " (code "sx-swap") ". Server renders everything. No client state. 90% of a typical application."))
(div :class "rounded border border-stone-200 p-4"
(div :class "font-semibold text-stone-800" "Level 1: Local DOM Operations")
(p :class "text-sm text-stone-600 mt-1"
"Imperative escapes: " (code "toggle!") ", " (code "set-attr!") ", " (code "on-event") ". Micro-interactions too small for a server round-trip."))
(div :class "rounded border border-violet-300 bg-violet-50 p-4"
(div :class "font-semibold text-violet-900" "Level 2: Reactive Islands")
(p :class "text-sm text-stone-600 mt-1"
(code "defisland") " components with local signals. Fine-grained DOM updates " (em "without") " virtual DOM, diffing, or component re-renders. A signal change updates only the DOM nodes that read it."))
(div :class "rounded border border-stone-200 p-4"
(div :class "font-semibold text-stone-800" "Level 3: Connected Islands")
(p :class "text-sm text-stone-600 mt-1"
"Islands that share state via signal props or named stores (" (code "def-store") " / " (code "use-store") ")."))))
(~doc-section :title "Signal Primitives" :id "signals"
(~doc-code :code (highlight "(signal v) ;; create a reactive container\n(deref s) ;; read value — subscribes in reactive context\n(reset! s v) ;; write new value — notifies subscribers\n(swap! s f) ;; update via function: (f old-value)\n(computed fn) ;; derived signal — auto-tracks dependencies\n(effect fn) ;; side effect — re-runs when deps change\n(batch fn) ;; group writes — one notification pass" "lisp"))
(p "Signals are values, not hooks. Create them anywhere — conditionals, loops, closures. No rules of hooks. Pass them as arguments, store them in dicts, share between islands."))
(~doc-section :title "Island Lifecycle" :id "lifecycle"
(ol :class "space-y-2 text-stone-600 list-decimal list-inside"
(li (strong "Definition: ") (code "defisland") " registers a reactive component (like " (code "defcomp") " + island flag)")
(li (strong "Server render: ") "Body evaluated with initial values. " (code "deref") " returns plain value. Output wrapped in " (code "data-sx-island") " / " (code "data-sx-state"))
(li (strong "Client hydration: ") "Finds " (code "data-sx-island") " elements, creates signals from serialized state, re-renders in reactive context")
(li (strong "Updates: ") "Signal changes update only subscribed DOM nodes. No full island re-render")
(li (strong "Disposal: ") "Island removed from DOM — all signals and effects cleaned up via " (code "with-island-scope"))))
(~doc-section :title "Implementation Status" :id "status"
(p :class "text-stone-600 mb-3" "All signal logic lives in " (code ".sx") " spec files and is bootstrapped to JavaScript and Python. No SX-specific logic in host languages.")
(div :class "overflow-x-auto rounded border border-stone-200"
(table :class "w-full text-left text-sm"
(thead (tr :class "border-b border-stone-200 bg-stone-100"
(th :class "px-3 py-2 font-medium text-stone-600" "Layer")
(th :class "px-3 py-2 font-medium text-stone-600" "Status")
(th :class "px-3 py-2 font-medium text-stone-600" "Files")))
(tbody
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Signal runtime spec")
(td :class "px-3 py-2 text-green-700 font-medium" "Done")
(td :class "px-3 py-2 font-mono text-xs text-stone-500" "signals.sx (291 lines)"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "defisland special form")
(td :class "px-3 py-2 text-green-700 font-medium" "Done")
(td :class "px-3 py-2 font-mono text-xs text-stone-500" "eval.sx, special-forms.sx, render.sx"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "DOM adapter (reactive rendering)")
(td :class "px-3 py-2 text-green-700 font-medium" "Done")
(td :class "px-3 py-2 font-mono text-xs text-stone-500" "adapter-dom.sx (+140 lines)"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "HTML adapter (SSR)")
(td :class "px-3 py-2 text-green-700 font-medium" "Done")
(td :class "px-3 py-2 font-mono text-xs text-stone-500" "adapter-html.sx (+65 lines)"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "JS bootstrapper")
(td :class "px-3 py-2 text-green-700 font-medium" "Done")
(td :class "px-3 py-2 font-mono text-xs text-stone-500" "bootstrap_js.py, sx-ref.js (4769 lines)"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Python bootstrapper")
(td :class "px-3 py-2 text-green-700 font-medium" "Done")
(td :class "px-3 py-2 font-mono text-xs text-stone-500" "bootstrap_py.py, sx_ref.py"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Test suite")
(td :class "px-3 py-2 text-green-700 font-medium" "17/17")
(td :class "px-3 py-2 font-mono text-xs text-stone-500" "test-signals.sx"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Named stores (L3)")
(td :class "px-3 py-2 text-green-700 font-medium" "Spec'd")
(td :class "px-3 py-2 font-mono text-xs text-stone-500" "signals.sx: def-store, use-store, clear-stores"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Event bridge")
(td :class "px-3 py-2 text-green-700 font-medium" "Spec'd")
(td :class "px-3 py-2 font-mono text-xs text-stone-500" "signals.sx: emit-event, on-event, bridge-event"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Client hydration")
(td :class "px-3 py-2 text-green-700 font-medium" "Spec'd")
(td :class "px-3 py-2 font-mono text-xs text-stone-500" "boot.sx: sx-hydrate-islands, hydrate-island, dispose-island"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Event bindings")
(td :class "px-3 py-2 text-green-700 font-medium" "Spec'd")
(td :class "px-3 py-2 font-mono text-xs text-stone-500" "adapter-dom.sx: :on-click → domListen"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "data-sx-emit processing")
(td :class "px-3 py-2 text-green-700 font-medium" "Spec'd")
(td :class "px-3 py-2 font-mono text-xs text-stone-500" "orchestration.sx: process-emit-elements"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Island disposal")
(td :class "px-3 py-2 text-green-700 font-medium" "Done")
(td :class "px-3 py-2 font-mono text-xs text-stone-500" "boot.sx, orchestration.sx: dispose-islands-in pre-swap"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Reactive list")
(td :class "px-3 py-2 text-green-700 font-medium" "Done")
(td :class "px-3 py-2 font-mono text-xs text-stone-500" "adapter-dom.sx: map + deref auto-upgrades"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Input binding")
(td :class "px-3 py-2 text-green-700 font-medium" "Done")
(td :class "px-3 py-2 font-mono text-xs text-stone-500" "adapter-dom.sx: :bind signal, bind-input"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Keyed reconciliation")
(td :class "px-3 py-2 text-green-700 font-medium" "Done")
(td :class "px-3 py-2 font-mono text-xs text-stone-500" "adapter-dom.sx: :key attr, extract-key"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Refs")
(td :class "px-3 py-2 text-green-700 font-medium" "Done")
(td :class "px-3 py-2 font-mono text-xs text-stone-500" "signals.sx: ref, ref-get, ref-set!, :ref attr"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Portals")
(td :class "px-3 py-2 text-green-700 font-medium" "Done")
(td :class "px-3 py-2 font-mono text-xs text-stone-500" "adapter-dom.sx: portal render-dom form"))
(tr
(td :class "px-3 py-2 text-stone-700" "Phase 2 remaining")
(td :class "px-3 py-2 text-stone-500 font-medium" "P2")
(td :class "px-3 py-2 font-mono text-xs text-stone-500"
(a :href "/reactive-islands/phase2" :sx-get "/reactive-islands/phase2" :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true" :class "text-violet-700 underline" "Error boundaries, suspense, transitions")))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Error boundaries")
(td :class "px-3 py-2 text-green-700 font-medium" "Done")
(td :class "px-3 py-2 font-mono text-xs text-stone-500" "adapter-dom.sx: error-boundary render-dom form"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Suspense + resource")
(td :class "px-3 py-2 text-green-700 font-medium" "Done")
(td :class "px-3 py-2 font-mono text-xs text-stone-500" "adapter-dom.sx, signals.sx"))
(tr
(td :class "px-3 py-2 text-stone-700" "Transitions")
(td :class "px-3 py-2 text-green-700 font-medium" "Done")
(td :class "px-3 py-2 font-mono text-xs text-stone-500" "signals.sx: transition, schedule-idle"))))))))
;; ---------------------------------------------------------------------------
;; 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)
(do (reset! running false)
(reset! elapsed 0)))
"Reset")))))
;; 5. Reactive list — map over a signal, auto-updates when signal changes
(defisland ~demo-reactive-list ()
(let ((next-id (signal 1))
(items (signal (list)))
(add-item (fn (e)
(batch (fn ()
(swap! items (fn (old)
(append old (dict "id" (deref next-id)
"text" (str "Item " (deref next-id))))))
(swap! next-id inc)))))
(remove-item (fn (id)
(swap! items (fn (old)
(filter (fn (item) (not (= (get item "id") id))) old))))))
(div :class "rounded border border-violet-200 bg-violet-50 p-4 my-4"
(div :class "flex items-center gap-3 mb-3"
(button :class "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700"
:on-click add-item
"Add Item")
(span :class "text-sm text-stone-500"
(deref (computed (fn () (len (deref items))))) " items"))
(ul :class "space-y-1"
(map (fn (item)
(li :key (str (get item "id"))
:class "flex items-center justify-between bg-white rounded px-3 py-2 text-sm"
(span (get item "text"))
(button :class "text-stone-400 hover:text-red-500 text-xs"
:on-click (fn (e) (remove-item (get item "id")))
"✕")))
(deref items))))))
;; 6. Input binding — two-way signal binding for form elements
(defisland ~demo-input-binding ()
(let ((name (signal ""))
(agreed (signal false)))
(div :class "rounded border border-violet-200 bg-violet-50 p-4 my-4 space-y-3"
(div :class "flex items-center gap-3"
(input :type "text" :bind name
:placeholder "Type your name..."
:class "px-3 py-1.5 rounded border border-stone-300 text-sm focus:outline-none focus:border-violet-400 w-48")
(span :class "text-sm text-stone-600"
"Hello, "
(strong (deref name))
"!"))
(div :class "flex items-center gap-2"
(input :type "checkbox" :bind agreed :id "agree-cb"
:class "rounded border-stone-300")
(label :for "agree-cb" :class "text-sm text-stone-600" "I agree to the terms"))
(when (deref agreed)
(p :class "text-sm text-green-700" "Thanks for agreeing!")))))
;; 7. Refs — mutable boxes + DOM element access
(defisland ~demo-refs ()
(let ((input-ref (ref nil))
(count (signal 0)))
(div :class "rounded border border-violet-200 bg-violet-50 p-4 my-4 space-y-3"
(div :class "flex items-center gap-3"
(input :type "text" :ref input-ref
:placeholder "Focus me with the button..."
:class "px-3 py-1.5 rounded border border-stone-300 text-sm focus:outline-none focus:border-violet-400 w-48")
(button :class "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700"
:on-click (fn (e)
(do
(dom-focus (ref-get input-ref))
(swap! count inc)))
"Focus Input")
(span :class "text-sm text-stone-500"
"Focused " (deref count) " times"))
(p :class "text-xs text-stone-400"
"The ref holds a mutable reference to the input element. Clicking the button calls focus() imperatively — no signal needed."))))
;; 8. Portal — render into a remote DOM target
(defisland ~demo-portal ()
(let ((open? (signal false)))
(div :class "rounded border border-violet-200 bg-violet-50 p-4 my-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! open? not))
(if (deref open?) "Close Modal" "Open Modal"))
(portal "#portal-root"
(when (deref open?)
(div :class "fixed inset-0 bg-black/50 flex items-center justify-center z-50"
:on-click (fn (e) (reset! open? false))
(div :class "bg-white rounded-lg p-6 max-w-md shadow-xl"
:on-click (fn (e) (stop-propagation e))
(h2 :class "text-lg font-bold text-stone-800 mb-2" "Portal Modal")
(p :class "text-stone-600 text-sm mb-4"
"This content is rendered into " (code "#portal-root") " — outside the island's DOM subtree. It escapes overflow:hidden, z-index stacking, and layout constraints.")
(button :class "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700"
:on-click (fn (e) (reset! open? false))
"Close"))))))))
;; ---------------------------------------------------------------------------
;; Demo page — shows what's been implemented
;; ---------------------------------------------------------------------------
(defcomp ~reactive-islands-demo-content ()
(~doc-page :title "Reactive Islands Demo"
(~doc-section :title "What this demonstrates" :id "what"
(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 primitives — callable 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. 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. 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 + 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. 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. Reactive List" :id "demo-reactive-list"
(p "When " (code "map") " is used with " (code "(deref signal)") " inside an island, it auto-upgrades to a reactive list. With " (code ":key") " attributes, existing DOM nodes are reused across updates — only additions, removals, and reorderings touch the DOM.")
(~demo-reactive-list)
(~doc-code :code (highlight "(defisland ~demo-reactive-list ()\n (let ((next-id (signal 1))\n (items (signal (list)))\n (add-item (fn (e)\n (batch (fn ()\n (swap! items (fn (old)\n (append old (dict \"id\" (deref next-id)\n \"text\" (str \"Item \" (deref next-id))))))\n (swap! next-id inc)))))\n (remove-item (fn (id)\n (swap! items (fn (old)\n (filter (fn (item) (not (= (get item \"id\") id))) old))))))\n (div\n (button :on-click add-item \"Add Item\")\n (span (deref (computed (fn () (len (deref items))))) \" items\")\n (ul\n (map (fn (item)\n (li :key (str (get item \"id\"))\n (span (get item \"text\"))\n (button :on-click (fn (e) (remove-item (get item \"id\"))) \"✕\")))\n (deref items))))))" "lisp"))
(p (code ":key") " identifies each list item. When items change, the reconciler matches old and new keys — reusing existing DOM nodes, inserting new ones, and removing stale ones. Without keys, the list falls back to clear-and-rerender. " (code "batch") " groups the two signal writes into one update pass."))
(~doc-section :title "6. Input Binding" :id "demo-input-binding"
(p "The " (code ":bind") " attribute creates a two-way link between a signal and a form element. Type in the input — the signal updates. Change the signal — the input updates. Works with text inputs, checkboxes, radios, textareas, and selects.")
(~demo-input-binding)
(~doc-code :code (highlight "(defisland ~demo-input-binding ()\n (let ((name (signal \"\"))\n (agreed (signal false)))\n (div\n (input :type \"text\" :bind name\n :placeholder \"Type your name...\")\n (span \"Hello, \" (strong (deref name)) \"!\")\n (input :type \"checkbox\" :bind agreed)\n (when (deref agreed)\n (p \"Thanks for agreeing!\")))))" "lisp"))
(p (code ":bind") " detects the element type automatically — text inputs use " (code "value") " + " (code "input") " event, checkboxes use " (code "checked") " + " (code "change") " event. The effect only updates the DOM when the value actually changed, preventing cursor jump."))
(~doc-section :title "7. Refs" :id "demo-refs"
(p "A " (code "ref") " is a mutable box that does " (em "not") " trigger reactivity. Like React's " (code "useRef") " — holds values between renders and provides imperative DOM access via " (code ":ref") " attribute.")
(~demo-refs)
(~doc-code :code (highlight "(defisland ~demo-refs ()\n (let ((input-ref (ref nil))\n (count (signal 0)))\n (div\n (input :type \"text\" :ref input-ref\n :placeholder \"Focus me with the button...\")\n (button :on-click (fn (e)\n (do\n (dom-focus (ref-get input-ref))\n (swap! count inc)))\n \"Focus Input\")\n (span \"Focused \" (deref count) \" times\"))))" "lisp"))
(p (code ":ref") " on an element sets " (code "ref.current") " to the DOM node after rendering. " (code "ref-get") " and " (code "ref-set!") " are non-reactive — writing to a ref doesn't trigger effects. Use refs for focus management, animations, canvas contexts, and anything requiring imperative DOM access."))
(~doc-section :title "8. Portals" :id "demo-portal"
(p "A " (code "portal") " renders children into a DOM node " (em "outside") " the island's subtree. Essential for modals, tooltips, and toasts — anything that must escape " (code "overflow:hidden") " or z-index stacking.")
(~demo-portal)
(~doc-code :code (highlight "(defisland ~demo-portal ()\n (let ((open? (signal false)))\n (div\n (button :on-click (fn (e) (swap! open? not))\n (if (deref open?) \"Close Modal\" \"Open Modal\"))\n (portal \"#portal-root\"\n (when (deref open?)\n (div :class \"fixed inset-0 bg-black/50 ...\"\n :on-click (fn (e) (reset! open? false))\n (div :class \"bg-white rounded-lg p-6 ...\"\n :on-click (fn (e) (stop-propagation e))\n (h2 \"Portal Modal\")\n (p \"Rendered outside the island's DOM.\")\n (button :on-click (fn (e) (reset! open? false))\n \"Close\"))))))))" "lisp"))
(p "The portal content lives in " (code "#portal-root") " (typically at the page body level), not inside the island. On island disposal, portal content is automatically removed from its target — the " (code "register-in-scope") " mechanism handles cleanup."))
(~doc-section :title "9. How defisland Works" :id "how-defisland"
(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 "10. 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).")
(~doc-code :code (highlight ";; Signal basics (6 tests)\n(assert-true (signal? (signal 42)))\n(assert-equal 42 (deref (signal 42)))\n(assert-equal 5 (deref 5)) ;; non-signal passthrough\n\n;; reset! changes value\n(let ((s (signal 0)))\n (reset! s 10)\n (assert-equal 10 (deref s)))\n\n;; reset! does NOT notify when value unchanged (identical? check)\n\n;; Computed (3 tests)\n(let ((a (signal 3)) (b (signal 4))\n (sum (computed (fn () (+ (deref a) (deref b))))))\n (assert-equal 7 (deref sum))\n (reset! a 10)\n (assert-equal 14 (deref sum)))\n\n;; Effects (4 tests) — immediate run, re-run on change, dispose, cleanup\n;; Batch (1 test) — defers notifications, deduplicates subscribers\n;; defisland (3 tests) — creates island, callable, accepts children" "lisp"))
(p :class "mt-2 text-sm text-stone-500" "Run: " (code "python3 shared/sx/tests/run.py signals")))
(~doc-section :title "What's next" :id "next"
(p "Phase 1 and Phase 2 are complete. The reactive islands system now includes: signals, effects, computed values, islands, disposal, stores, event bridges, reactive DOM rendering, input binding, keyed reconciliation, refs, portals, error boundaries, suspense, resource, and transitions.")
(p "See the " (a :href "/reactive-islands/phase2" :sx-get "/reactive-islands/phase2" :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true" :class "text-violet-700 underline" "Phase 2 plan") " for the full feature list and design details."))))
;; ---------------------------------------------------------------------------
;; Event Bridge — DOM events for lake→island communication
;; ---------------------------------------------------------------------------
(defcomp ~reactive-islands-event-bridge-content ()
(~doc-page :title "Event Bridge"
(~doc-section :title "The Problem" :id "problem"
(p "A reactive island can contain server-rendered content — an htmx \"lake\" that swaps via " (code "sx-get") "/" (code "sx-post") ". The lake content is pure HTML from the server. It has no access to island signals.")
(p "But sometimes the lake needs to " (em "tell") " the island something happened. A server-rendered \"Add to Cart\" button needs to update the island's cart signal. A server-rendered search form needs to feed results into the island's result signal.")
(p "The event bridge solves this: DOM custom events bubble from the lake up to the island, where an effect listens and updates signals."))
(~doc-section :title "How it works" :id "how"
(p "Three components:")
(ol :class "space-y-2 text-stone-600 list-decimal list-inside"
(li (strong "Server emits: ") "Server-rendered elements carry " (code "data-sx-emit") " attributes. When the user interacts, the client dispatches a CustomEvent.")
(li (strong "Event bubbles: ") "The event bubbles up through the DOM tree until it reaches the island container.")
(li (strong "Effect catches: ") "An effect inside the island listens for the event name and updates a signal."))
(~doc-code :code (highlight ";; Island with an event bridge\n(defisland ~product-page (&key product)\n (let ((cart-items (signal (list))))\n\n ;; Bridge: listen for \"cart:add\" events from server content\n (bridge-event container \"cart:add\" cart-items\n (fn (detail)\n (append (deref cart-items)\n (dict :id (get detail \"id\")\n :name (get detail \"name\")\n :price (get detail \"price\")))))\n\n (div\n ;; Island header with reactive cart count\n (div :class \"flex justify-between\"\n (h1 (get product \"name\"))\n (span :class \"badge\" (length (deref cart-items)) \" items\"))\n\n ;; htmx lake — server-rendered product details\n ;; This content is swapped by sx-get, not rendered by the island\n (div :id \"product-details\"\n :sx-get (str \"/products/\" (get product \"id\") \"/details\")\n :sx-swap \"innerHTML\"\n :sx-trigger \"load\"))))" "lisp"))
(p "The server handler for " (code "/products/:id/details") " returns HTML with emit attributes:")
(~doc-code :code (highlight ";; Server-rendered response (pure HTML, no signals)\n(div\n (p (get product \"description\"))\n (div :class \"flex gap-2 mt-4\"\n (button\n :data-sx-emit \"cart:add\"\n :data-sx-emit-detail (json-serialize\n (dict :id (get product \"id\")\n :name (get product \"name\")\n :price (get product \"price\")))\n :class \"bg-violet-600 text-white px-4 py-2 rounded\"\n \"Add to Cart\")))" "lisp"))
(p "The button is plain server HTML. When clicked, the client's event bridge dispatches " (code "cart:add") " with the JSON detail. The island effect catches it and appends to " (code "cart-items") ". The badge updates reactively."))
(~doc-section :title "Why signals survive swaps" :id "survival"
(p "Signals live in JavaScript memory (closures), not in the DOM. When htmx swaps content inside an island:")
(ul :class "space-y-2 text-stone-600 list-disc pl-5"
(li (strong "Swap inside island: ") "Signals survive. The lake content is replaced but the island's signal closures are untouched. Effects re-bind to new DOM nodes if needed.")
(li (strong "Swap outside island: ") "Signals survive. The island is not affected by swaps to other parts of the page.")
(li (strong "Swap replaces island: ") "Signals are " (em "lost") ". The island is disposed. This is where " (a :href "/reactive-islands/named-stores" :sx-get "/reactive-islands/named-stores" :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true" :class "text-violet-700 underline" "named stores") " come in — they persist at page level, surviving island destruction.")))
(~doc-section :title "Spec" :id "spec"
(p "The event bridge is spec'd in " (code "signals.sx") " (sections 12-13). Three functions:")
(~doc-code :code (highlight ";; Low-level: dispatch a custom event\n(emit-event el \"cart:add\" {:id 42 :name \"Widget\"})\n\n;; Low-level: listen for a custom event\n(on-event container \"cart:add\" (fn (e)\n (swap! items (fn (old) (append old (event-detail e))))))\n\n;; High-level: bridge an event directly to a signal\n;; Creates an effect with automatic cleanup on dispose\n(bridge-event container \"cart:add\" items\n (fn (detail) (append (deref items) detail)))" "lisp"))
(p "Platform interface required:")
(div :class "overflow-x-auto rounded border border-stone-200 mt-2"
(table :class "w-full text-left text-sm"
(thead (tr :class "border-b border-stone-200 bg-stone-100"
(th :class "px-3 py-2 font-medium text-stone-600" "Function")
(th :class "px-3 py-2 font-medium text-stone-600" "Description")))
(tbody
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "(dom-listen el name handler)")
(td :class "px-3 py-2 text-stone-700" "Attach event listener, return remove function"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "(dom-dispatch el name detail)")
(td :class "px-3 py-2 text-stone-700" "Dispatch CustomEvent with detail, bubbles: true"))
(tr
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "(event-detail e)")
(td :class "px-3 py-2 text-stone-700" "Extract .detail from CustomEvent"))))))))
;; ---------------------------------------------------------------------------
;; Named Stores — page-level signal containers
;; ---------------------------------------------------------------------------
(defcomp ~reactive-islands-named-stores-content ()
(~doc-page :title "Named Stores"
(~doc-section :title "The Problem" :id "problem"
(p "Islands are isolated by default. Signal props work when islands are adjacent, but not when they are:")
(ul :class "space-y-1 text-stone-600 list-disc pl-5"
(li "Distant in the DOM tree (header badge + drawer island)")
(li "Defined in different " (code ".sx") " files")
(li "Destroyed and recreated by htmx swaps"))
(p "Named stores solve all three. A store is a named collection of signals that lives at " (em "page") " scope, not island scope."))
(~doc-section :title "def-store / use-store" :id "api"
(~doc-code :code (highlight ";; Create a named store — called once at page level\n;; The init function creates signals and computeds\n(def-store \"cart\" (fn ()\n (let ((items (signal (list))))\n (dict\n :items items\n :count (computed (fn () (length (deref items))))\n :total (computed (fn () (reduce + 0\n (map (fn (i) (get i \"price\")) (deref items)))))))))\n\n;; Use the store from any island — returns the signal dict\n(defisland ~cart-badge ()\n (let ((store (use-store \"cart\")))\n (span :class \"badge bg-violet-100 text-violet-800 px-2 py-1 rounded-full\"\n (deref (get store \"count\")))))\n\n(defisland ~cart-drawer ()\n (let ((store (use-store \"cart\")))\n (div :class \"p-4\"\n (h2 \"Cart\")\n (ul (map (fn (item)\n (li :class \"flex justify-between py-1\"\n (span (get item \"name\"))\n (span :class \"text-stone-500\" \"\\u00A3\" (get item \"price\"))))\n (deref (get store \"items\"))))\n (div :class \"border-t pt-2 font-semibold\"\n \"Total: \\u00A3\" (deref (get store \"total\"))))))" "lisp"))
(p (code "def-store") " is " (strong "idempotent") " — calling it again with the same name returns the existing store. This means multiple components can call " (code "def-store") " defensively without double-creating."))
(~doc-section :title "Lifecycle" :id "lifecycle"
(ol :class "space-y-2 text-stone-600 list-decimal list-inside"
(li (strong "Page load: ") (code "def-store") " creates the store in a global registry. Signals are initialized.")
(li (strong "Island hydration: ") "Each island calls " (code "use-store") " to get the shared signal dict. Derefs create subscriptions.")
(li (strong "Island swap: ") "An island is destroyed by htmx swap. Its effects are cleaned up. But the store " (em "persists") " — it's in the page-level registry, not the island scope.")
(li (strong "Island recreation: ") "The new island calls " (code "use-store") " again. Gets the same signals. Reconnects reactively. User state is preserved.")
(li (strong "Full page navigation: ") (code "clear-stores") " wipes the registry. Clean slate.")))
(~doc-section :title "Combining with event bridge" :id "combined"
(p "Named stores + event bridge = full lake→island→island communication:")
(~doc-code :code (highlight ";; Store persists across island lifecycle\n(def-store \"cart\" (fn () ...))\n\n;; Island 1: product page with htmx lake\n(defisland ~product-island ()\n (let ((store (use-store \"cart\")))\n ;; Bridge server-rendered \"Add\" buttons to store\n (bridge-event container \"cart:add\" (get store \"items\")\n (fn (detail) (append (deref (get store \"items\")) detail)))\n ;; Lake content swapped via sx-get\n (div :id \"product-content\" :sx-get \"/products/featured\")))\n\n;; Island 2: cart badge in header (distant in DOM)\n(defisland ~cart-badge ()\n (let ((store (use-store \"cart\")))\n (span (deref (get store \"count\")))))" "lisp"))
(p "User clicks \"Add to Cart\" in server-rendered product content. " (code "cart:add") " event fires. Product island catches it via bridge. Store's " (code "items") " signal updates. Cart badge — in a completely different island — updates reactively because it reads the same signal."))
(~doc-section :title "Spec" :id "spec"
(p "Named stores are spec'd in " (code "signals.sx") " (section 12). Three functions:")
(div :class "overflow-x-auto rounded border border-stone-200"
(table :class "w-full text-left text-sm"
(thead (tr :class "border-b border-stone-200 bg-stone-100"
(th :class "px-3 py-2 font-medium text-stone-600" "Function")
(th :class "px-3 py-2 font-medium text-stone-600" "Description")))
(tbody
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "(def-store name init-fn)")
(td :class "px-3 py-2 text-stone-700" "Create or return existing named store. init-fn returns a dict of signals/computeds."))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "(use-store name)")
(td :class "px-3 py-2 text-stone-700" "Get existing store by name. Errors if store doesn't exist."))
(tr
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "(clear-stores)")
(td :class "px-3 py-2 text-stone-700" "Wipe all stores. Called on full page navigation."))))))))
;; ---------------------------------------------------------------------------
;; Plan — the full design document (moved from plans section)
;; ---------------------------------------------------------------------------
(defcomp ~reactive-islands-plan-content ()
(~doc-page :title "Reactive Islands Plan"
(~doc-section :title "Context" :id "context"
(p "SX already has a sliding bar for " (em "where") " rendering happens — server-side HTML, SX wire format for client rendering, or any point between. This is the isomorphism bar. It controls the render boundary.")
(p "There is a second bar, orthogonal to the first: " (em "how state flows.") " On one end, all state lives on the server — every user action is a round-trip, every UI update is a fresh render. This is the htmx model. On the other end, state lives on the client — signals, subscriptions, fine-grained DOM updates without server involvement. This is the React model.")
(p "These two bars are independent. You can have server-rendered HTML with client state (SSR + hydrated React). You can have client-rendered components with server state (current SX). The combination creates four quadrants:")
(div :class "overflow-x-auto mt-4 mb-4"
(table :class "w-full text-sm text-left"
(thead
(tr :class "border-b border-stone-200"
(th :class "py-2 px-3 font-semibold text-stone-700" "")
(th :class "py-2 px-3 font-semibold text-stone-700" "Server State")
(th :class "py-2 px-3 font-semibold text-stone-700" "Client State")))
(tbody :class "text-stone-600"
(tr :class "border-b border-stone-100"
(td :class "py-2 px-3 font-semibold text-stone-700" "Server Rendering")
(td :class "py-2 px-3" "Pure hypermedia (htmx)")
(td :class "py-2 px-3" "SSR + hydrated islands (Next.js)"))
(tr :class "border-b border-stone-100"
(td :class "py-2 px-3 font-semibold text-stone-700" "Client Rendering")
(td :class "py-2 px-3" "SX wire format (current)")
(td :class "py-2 px-3 font-semibold text-violet-700" "Reactive islands (this plan)")))))
(p "Today SX occupies the bottom-left quadrant — client-rendered components with server state. This plan adds the bottom-right: " (strong "reactive islands") " with client-local signals. A page can mix all four quadrants. Most content stays hypermedia. Interactive regions opt into reactivity. The author controls where each component sits on both bars."))
(~doc-section :title "The Spectrum" :id "spectrum"
(p "Four levels of client interactivity. Each is independently valuable. Each is opt-in per component.")
(~doc-subsection :title "Level 0: Pure Hypermedia"
(p "The default. " (code "sx-get") ", " (code "sx-post") ", " (code "sx-swap") ". Server renders everything. Client swaps fragments. No client state. No JavaScript state management. This is where 90% of a typical application should live."))
(~doc-subsection :title "Level 1: Local DOM Operations"
(p "Imperative escape hatches for micro-interactions too small for a server round-trip: toggling a menu, switching a tab, showing a tooltip. " (code "toggle!") ", " (code "set-attr!") ", " (code "on-event") ". No reactive graph. Just do the thing directly."))
(~doc-subsection :title "Level 2: Reactive Islands"
(p (code "defisland") " components with local signals. Fine-grained DOM updates — no virtual DOM, no diffing, no component re-renders. A signal change updates only the DOM nodes that read it. Islands are isolated by default. The server can render their initial state."))
(~doc-subsection :title "Level 3: Connected Islands"
(p "Islands that share state via signal props or named stores (" (code "def-store") " / " (code "use-store") "). Plus event bridges for htmx lake-to-island communication. This is where SX starts to feel like React — but only in the regions that need it. The surrounding page remains hypermedia.")))
(~doc-section :title "htmx Lakes" :id "lakes"
(p "An htmx lake is server-driven content " (em "inside") " a reactive island. The island provides the reactive boundary; the lake content is swapped via " (code "sx-get") "/" (code "sx-post") " like normal hypermedia.")
(p "This works because signals live in JavaScript closures, not in the DOM. When a swap replaces lake content, the island's signals are unaffected. The lake can communicate back to the island via the " (a :href "/reactive-islands/event-bridge" :sx-get "/reactive-islands/event-bridge" :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true" :class "text-violet-700 underline" "event bridge") ".")
(~doc-subsection :title "Navigation scenarios"
(div :class "space-y-3"
(div :class "rounded border border-green-200 bg-green-50 p-3"
(div :class "font-semibold text-green-800" "Swap inside island")
(p :class "text-sm text-stone-600 mt-1" "Lake content replaced. Signals survive. Effects can rebind to new DOM. User state intact."))
(div :class "rounded border border-green-200 bg-green-50 p-3"
(div :class "font-semibold text-green-800" "Swap outside island")
(p :class "text-sm text-stone-600 mt-1" "Different part of page updated. Island completely unaffected. User state intact."))
(div :class "rounded border border-amber-200 bg-amber-50 p-3"
(div :class "font-semibold text-amber-800" "Swap replaces island")
(p :class "text-sm text-stone-600 mt-1" "Island disposed. Local signals lost. Named stores persist — new island reconnects via use-store."))
(div :class "rounded border border-stone-200 p-3"
(div :class "font-semibold text-stone-800" "Full page navigation")
(p :class "text-sm text-stone-600 mt-1" "Everything cleared. clean slate. clear-stores wipes the registry.")))))
(~doc-section :title "Reactive DOM Rendering" :id "reactive-rendering"
(p "The existing " (code "renderDOM") " function walks the AST and creates DOM nodes. Inside an island, it becomes signal-aware:")
(~doc-subsection :title "Text bindings"
(~doc-code :code (highlight ";; (span (deref count)) creates:\n;; const text = document.createTextNode(sig.value)\n;; effect(() => text.nodeValue = sig.value)" "lisp"))
(p "Only the text node updates. The span is untouched."))
(~doc-subsection :title "Attribute bindings"
(~doc-code :code (highlight ";; (div :class (str \"panel \" (if (deref open?) \"visible\" \"hidden\")))\n;; effect(() => div.className = ...)" "lisp")))
(~doc-subsection :title "Conditional fragments"
(~doc-code :code (highlight ";; (when (deref show?) (~details)) creates:\n;; A marker comment node, then:\n;; effect(() => show ? insert-after(marker, render(~details)) : remove)" "lisp"))
(p "Equivalent to SolidJS's " (code "Show") " — but falls out naturally from the evaluator."))
(~doc-subsection :title "List rendering"
(~doc-code :code (highlight "(map (fn (item) (li :key (get item \"id\") (get item \"name\")))\n (deref items))" "lisp"))
(p "Keyed elements are reused and reordered. Unkeyed elements are morphed.")))
(~doc-section :title "Status" :id "status"
(div :class "overflow-x-auto rounded border border-stone-200"
(table :class "w-full text-left text-sm"
(thead (tr :class "border-b border-stone-200 bg-stone-100"
(th :class "px-3 py-2 font-medium text-stone-600" "Task")
(th :class "px-3 py-2 font-medium text-stone-600" "Status")
(th :class "px-3 py-2 font-medium text-stone-600" "Description")))
(tbody
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Signal runtime")
(td :class "px-3 py-2 text-green-700 font-medium" "Done")
(td :class "px-3 py-2 text-stone-700" "signals.sx: signal, deref, reset!, swap!, computed, effect, batch"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Named stores (L3)")
(td :class "px-3 py-2 text-green-700 font-medium" "Done")
(td :class "px-3 py-2 text-stone-700" "signals.sx: def-store, use-store, clear-stores"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Event bridge")
(td :class "px-3 py-2 text-green-700 font-medium" "Done")
(td :class "px-3 py-2 text-stone-700" "signals.sx: emit-event, on-event, bridge-event"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Event bindings")
(td :class "px-3 py-2 text-green-700 font-medium" "Done")
(td :class "px-3 py-2 text-stone-700" "adapter-dom.sx: :on-click (fn ...) → domListen"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "data-sx-emit")
(td :class "px-3 py-2 text-green-700 font-medium" "Done")
(td :class "px-3 py-2 text-stone-700" "orchestration.sx: auto-dispatch custom events from server content"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Client hydration")
(td :class "px-3 py-2 text-green-700 font-medium" "Done")
(td :class "px-3 py-2 text-stone-700" "boot.sx: hydrate-island, dispose-island, post-swap wiring"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Bootstrapping")
(td :class "px-3 py-2 text-green-700 font-medium" "Done")
(td :class "px-3 py-2 text-stone-700" "All functions transpiled to JS and Python, platform primitives implemented"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Island disposal")
(td :class "px-3 py-2 text-green-700 font-medium" "Done")
(td :class "px-3 py-2 text-stone-700" "boot.sx, orchestration.sx: effects/computeds auto-register disposers, pre-swap cleanup"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Reactive list")
(td :class "px-3 py-2 text-green-700 font-medium" "Done")
(td :class "px-3 py-2 text-stone-700" "adapter-dom.sx: map + deref auto-upgrades to reactive-list"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Input binding + keyed lists")
(td :class "px-3 py-2 text-green-700 font-medium" "Done")
(td :class "px-3 py-2 text-stone-700" "adapter-dom.sx: :bind signal, :key attr"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Refs + portals")
(td :class "px-3 py-2 text-green-700 font-medium" "Done")
(td :class "px-3 py-2 text-stone-700" "ref, ref-get, ref-set!, :ref, portal"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Error boundaries + suspense")
(td :class "px-3 py-2 text-green-700 font-medium" "Done")
(td :class "px-3 py-2 text-stone-700" "error-boundary, suspense, resource"))
(tr
(td :class "px-3 py-2 text-stone-700" "Transitions")
(td :class "px-3 py-2 text-green-700 font-medium" "Done")
(td :class "px-3 py-2 text-stone-700" "signals.sx: transition, schedule-idle"))))))
(~doc-section :title "Design Principles" :id "principles"
(ol :class "space-y-3 text-stone-600 list-decimal list-inside"
(li (strong "Islands are opt-in.") " " (code "defcomp") " remains the default. Components are inert unless you choose " (code "defisland") ". No reactive overhead for static content.")
(li (strong "Signals are values, not hooks.") " Create them anywhere. Pass them as arguments. Store them in dicts. No rules about calling order or conditional creation.")
(li (strong "Fine-grained, not component-grained.") " A signal change updates the specific DOM node that reads it. The island does not re-render. There is no virtual DOM and no diffing beyond the morph algorithm already in SxEngine.")
(li (strong "The server is still the authority.") " Islands handle client interactions. The server handles auth, data, routing. The server can push state into islands via OOB swaps. Islands can submit data to the server via " (code "sx-post") ".")
(li (strong "Spec-first.") " Signal semantics live in " (code "signals.sx") ". Bootstrapped to JS and Python. The same primitives will work in future hosts — Go, Rust, native.")
(li (strong "No build step.") " Reactive bindings are created at runtime during DOM rendering. No JSX compilation, no Babel transforms, no Vite plugins."))
(p :class "mt-4" "The recommendation from the " (a :href "/essays/client-reactivity" :class "text-violet-700 underline" "Client Reactivity") " essay was: \"Tier 4 probably never.\" This plan is what happens when the answer changes. The design avoids every footgun that essay warns about — no useState cascading to useEffect cascading to Context cascading to a state management library. Signals are one primitive. Islands are one boundary. The rest is composition."))))
;; ---------------------------------------------------------------------------
;; Phase 2 Plan — remaining reactive features
;; ---------------------------------------------------------------------------
(defcomp ~reactive-islands-phase2-content ()
(~doc-page :title "Phase 2: Completing the Reactive Toolkit"
(~doc-section :title "Where we are" :id "where"
(p "Phase 1 delivered the core reactive primitive: signals, effects, computed values, islands, disposal, stores, event bridges, and reactive DOM rendering. These are sufficient for any isolated interactive widget.")
(p "Phase 2 fills the gaps that appear when you try to build " (em "real application UI") " with islands — forms, modals, dynamic styling, efficient lists, error handling, and async loading. Each feature is independently valuable and independently shippable. None requires changes to the signal runtime.")
(div :class "overflow-x-auto rounded border border-stone-200 mt-4"
(table :class "w-full text-left text-sm"
(thead (tr :class "border-b border-stone-200 bg-stone-100"
(th :class "px-3 py-2 font-medium text-stone-600" "Feature")
(th :class "px-3 py-2 font-medium text-stone-600" "React equiv.")
(th :class "px-3 py-2 font-medium text-stone-600" "Priority")
(th :class "px-3 py-2 font-medium text-stone-600" "Spec file")))
(tbody
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Input binding")
(td :class "px-3 py-2 text-stone-500 text-xs" "controlled inputs")
(td :class "px-3 py-2 text-green-700 font-medium" "Done")
(td :class "px-3 py-2 font-mono text-xs text-stone-500" "adapter-dom.sx"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Keyed reconciliation")
(td :class "px-3 py-2 text-stone-500 text-xs" "key prop")
(td :class "px-3 py-2 text-green-700 font-medium" "Done")
(td :class "px-3 py-2 font-mono text-xs text-stone-500" "adapter-dom.sx"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Refs")
(td :class "px-3 py-2 text-stone-500 text-xs" "useRef")
(td :class "px-3 py-2 text-green-700 font-medium" "Done")
(td :class "px-3 py-2 font-mono text-xs text-stone-500" "signals.sx"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Portals")
(td :class "px-3 py-2 text-stone-500 text-xs" "createPortal")
(td :class "px-3 py-2 text-green-700 font-medium" "Done")
(td :class "px-3 py-2 font-mono text-xs text-stone-500" "adapter-dom.sx"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Error boundaries")
(td :class "px-3 py-2 text-stone-500 text-xs" "componentDidCatch")
(td :class "px-3 py-2 text-green-700 font-medium" "Done")
(td :class "px-3 py-2 font-mono text-xs text-stone-500" "adapter-dom.sx"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 text-stone-700" "Suspense")
(td :class "px-3 py-2 text-stone-500 text-xs" "Suspense + lazy")
(td :class "px-3 py-2 text-green-700 font-medium" "Done")
(td :class "px-3 py-2 font-mono text-xs text-stone-500" "adapter-dom.sx, signals.sx"))
(tr
(td :class "px-3 py-2 text-stone-700" "Transitions")
(td :class "px-3 py-2 text-stone-500 text-xs" "startTransition")
(td :class "px-3 py-2 text-green-700 font-medium" "Done")
(td :class "px-3 py-2 font-mono text-xs text-stone-500" "signals.sx"))))))
;; -----------------------------------------------------------------------
;; P0 — must have
;; -----------------------------------------------------------------------
(~doc-section :title "P0: Input Binding" :id "input-binding"
(p "You cannot build a form without two-way input binding. React uses controlled components — value is always driven by state, onChange feeds back. SX needs the same pattern but with signals instead of setState.")
(~doc-subsection :title "Design"
(p "A new " (code ":bind") " attribute on " (code "input") ", " (code "textarea") ", and " (code "select") " elements. It takes a signal and creates a bidirectional link: signal value flows into the element, user input flows back into the signal.")
(~doc-code :code (highlight ";; Bind a signal to an input\n(defisland ~login-form ()\n (let ((email (signal \"\"))\n (password (signal \"\")))\n (form :on-submit (fn (e)\n (dom-prevent-default e)\n (fetch-json \"POST\" \"/api/login\"\n (dict \"email\" (deref email)\n \"password\" (deref password))))\n (input :type \"email\" :bind email\n :placeholder \"Email\")\n (input :type \"password\" :bind password\n :placeholder \"Password\")\n (button :type \"submit\" \"Log in\"))))" "lisp"))
(p "The " (code ":bind") " attribute is handled in " (code "adapter-dom.sx") "'s element rendering. For a signal " (code "s") ":")
(ol :class "space-y-1 text-stone-600 list-decimal list-inside text-sm"
(li "Set the element's " (code "value") " to " (code "(deref s)") " initially")
(li "Create an effect: when " (code "s") " changes externally, update " (code "el.value"))
(li "Add an " (code "input") " event listener: on user input, call " (code "(reset! s el.value)"))
(li "For checkboxes/radios: bind to " (code "checked") " instead of " (code "value"))
(li "For select: bind to " (code "value") ", handle " (code "change") " event")))
(~doc-subsection :title "Spec changes"
(~doc-code :code (highlight ";; In adapter-dom.sx, inside render-dom-element:\n;; After processing :on-* event attrs, check for :bind\n(when (dict-has? kwargs \"bind\")\n (let ((sig (dict-get kwargs \"bind\")))\n (when (signal? sig)\n (bind-input el sig))))\n\n;; New function in adapter-dom.sx:\n(define bind-input\n (fn (el sig)\n (let ((tag (lower (dom-tag-name el)))\n (is-checkbox (or (= (dom-get-attr el \"type\") \"checkbox\")\n (= (dom-get-attr el \"type\") \"radio\"))))\n ;; Set initial value\n (if is-checkbox\n (dom-set-prop el \"checked\" (deref sig))\n (dom-set-prop el \"value\" (str (deref sig))))\n ;; Signal → element (effect, auto-tracked)\n (effect (fn ()\n (if is-checkbox\n (dom-set-prop el \"checked\" (deref sig))\n (let ((v (str (deref sig))))\n (when (!= (dom-get-prop el \"value\") v)\n (dom-set-prop el \"value\" v))))))\n ;; Element → signal (event listener)\n (dom-listen el (if is-checkbox \"change\" \"input\")\n (fn (e)\n (if is-checkbox\n (reset! sig (dom-get-prop el \"checked\"))\n (reset! sig (dom-get-prop el \"value\"))))))))" "lisp"))
(p "Platform additions: " (code "dom-set-prop") " and " (code "dom-get-prop") " (property access, not attribute — " (code ".value") " not " (code "getAttribute") "). These go in the boundary as IO primitives."))
(~doc-subsection :title "Derived patterns"
(p "Input binding composes with everything already built:")
(ul :class "space-y-1 text-stone-600 list-disc pl-5 text-sm"
(li (strong "Validation: ") (code "(computed (fn () (>= (len (deref email)) 3)))") " — derived from the bound signal")
(li (strong "Debounced search: ") "Effect with " (code "set-timeout") " cleanup, reading the bound signal")
(li (strong "Form submission: ") (code "(deref email)") " in the submit handler gives the current value")
(li (strong "Stores: ") "Bind to a store signal — multiple islands share the same form state"))))
(~doc-section :title "P0: Keyed List Reconciliation" :id "keyed-list"
(p (code "reactive-list") " currently clears all DOM nodes and re-renders from scratch on every signal change. This works for small lists but breaks down for large ones — focus is lost, animations restart, scroll position resets.")
(~doc-subsection :title "Design"
(p "When items have a " (code ":key") " attribute (or a key function), " (code "reactive-list") " should reconcile by key instead of clearing.")
(~doc-code :code (highlight ";; Keyed list — items matched by :key, reused across updates\n(defisland ~todo-list ()\n (let ((items (signal (list\n (dict \"id\" 1 \"text\" \"Buy milk\")\n (dict \"id\" 2 \"text\" \"Write spec\")\n (dict \"id\" 3 \"text\" \"Ship it\")))))\n (ul\n (map (fn (item)\n (li :key (get item \"id\")\n (span (get item \"text\"))\n (button :on-click (fn (e) ...)\n \"Remove\")))\n (deref items)))))" "lisp"))
(p "The reconciliation algorithm:")
(ol :class "space-y-1 text-stone-600 list-decimal list-inside text-sm"
(li "Extract key from each rendered child (from " (code ":key") " attr or item identity)")
(li "Build a map of " (code "old-key → DOM node") " from previous render")
(li "Walk new items: if key exists in old map, " (strong "reuse") " the DOM node (move to correct position). If not, render fresh.")
(li "Remove DOM nodes whose keys are absent from the new list")
(li "Result: minimum DOM mutations. Focus, scroll, animations preserved.")))
(~doc-subsection :title "Spec changes"
(~doc-code :code (highlight ";; In adapter-dom.sx, replace reactive-list's effect body:\n(define reactive-list\n (fn (map-fn items-sig env ns)\n (let ((marker (create-comment \"island-list\"))\n (key-map (dict)) ;; key → DOM node\n (key-order (list))) ;; current key order\n (effect (fn ()\n (let ((parent (dom-parent marker))\n (items (deref items-sig)))\n (when parent\n (let ((new-map (dict))\n (new-keys (list)))\n ;; Render or reuse each item\n (for-each (fn (item)\n (let ((rendered (render-item map-fn item env ns))\n (key (or (dom-get-attr rendered \"key\")\n (dom-get-data rendered \"key\")\n (identity-key item))))\n (dom-remove-attr rendered \"key\")\n (if (dict-has? key-map key)\n ;; Reuse existing\n (dict-set! new-map key (dict-get key-map key))\n ;; New node\n (dict-set! new-map key rendered))\n (append! new-keys key)))\n items)\n ;; Remove stale nodes\n (for-each (fn (k)\n (when (not (dict-has? new-map k))\n (dom-remove (dict-get key-map k))))\n key-order)\n ;; Reorder to match new-keys\n (let ((cursor marker))\n (for-each (fn (k)\n (let ((node (dict-get new-map k)))\n (when (not (= node (dom-next-sibling cursor)))\n (dom-insert-after cursor node))\n (set! cursor node)))\n new-keys))\n ;; Update state\n (set! key-map new-map)\n (set! key-order new-keys))))))\n marker)))" "lisp"))
(p "Falls back to current clear-and-rerender when no keys are present.")))
;; -----------------------------------------------------------------------
;; P1 — important
;; -----------------------------------------------------------------------
(~doc-section :title "P1: Refs" :id "refs"
(p "A ref is a mutable container that does " (em "not") " trigger reactivity when written. React's " (code "useRef") " is used for two things: holding mutable values between renders, and accessing DOM elements imperatively.")
(~doc-subsection :title "Design"
(~doc-code :code (highlight ";; ref — mutable box, no reactivity\n(define ref\n (fn (initial)\n (dict \"current\" initial)))\n\n(define ref-get (fn (r) (get r \"current\")))\n(define ref-set! (fn (r v) (dict-set! r \"current\" v)))\n\n;; Usage: holding mutable state without triggering effects\n(defisland ~canvas-demo ()\n (let ((frame-id (ref nil))\n (ctx (ref nil)))\n ;; Attach to canvas element via :ref\n (canvas :ref ctx :width 400 :height 300)\n ;; frame-id changes don't trigger re-renders\n (effect (fn ()\n (when (ref-get ctx)\n (draw-frame (ref-get ctx))\n (ref-set! frame-id\n (request-animation-frame\n (fn () (draw-frame (ref-get ctx))))))))))" "lisp"))
(p "Two features:")
(ol :class "space-y-1 text-stone-600 list-decimal list-inside text-sm"
(li (strong "Mutable box: ") (code "ref") " / " (code "ref-get") " / " (code "ref-set!") " — trivial, just a dict wrapper. Spec in " (code "signals.sx") ".")
(li (strong "DOM ref attribute: ") (code ":ref") " on an element sets " (code "ref.current") " to the DOM node after rendering. Spec in " (code "adapter-dom.sx") " — during element creation, if " (code ":ref") " is present, call " (code "(ref-set! r el)") "."))))
(~doc-section :title "P1: Portals" :id "portals"
(p "A portal renders children into a DOM node " (em "outside") " the island's subtree. Essential for modals, tooltips, dropdown menus, and toast notifications — anything that must escape overflow:hidden, z-index stacking, or layout constraints.")
(~doc-subsection :title "Design"
(~doc-code :code (highlight ";; portal — render children into a target element\n(defisland ~modal-trigger ()\n (let ((open? (signal false)))\n (div\n (button :on-click (fn (e) (swap! open? not))\n \"Open Modal\")\n\n ;; Portal: children rendered into #modal-root,\n ;; not into this island's DOM\n (portal \"#modal-root\"\n (when (deref open?)\n (div :class \"fixed inset-0 bg-black/50 flex items-center justify-center\"\n (div :class \"bg-white rounded-lg p-6 max-w-md\"\n (h2 \"Modal Title\")\n (p \"This is rendered outside the island's DOM subtree.\")\n (button :on-click (fn (e) (reset! open? false))\n \"Close\"))))))))" "lisp"))
(p "Implementation in " (code "adapter-dom.sx") ":")
(ol :class "space-y-1 text-stone-600 list-decimal list-inside text-sm"
(li (code "portal") " is a new render-dom form (add to " (code "RENDER_DOM_FORMS") " and " (code "dispatch-render-form") ")")
(li "First arg is a CSS selector string for the target container")
(li "Remaining args are children, rendered normally via " (code "render-to-dom"))
(li "Instead of returning the fragment, append it to the resolved target element")
(li "Return a comment marker in the original position (for disposal tracking)")
(li "On island disposal, portal content is removed from the target")))
(~doc-subsection :title "Disposal"
(p "Portals must participate in island disposal. When the island is destroyed, portal content must be removed from its remote target. The " (code "with-island-scope") " mechanism handles this — the portal registers a disposer that removes its children from the target element.")))
;; -----------------------------------------------------------------------
;; P2 — nice to have
;; -----------------------------------------------------------------------
(~doc-section :title "P2: Error Boundaries" :id "error-boundaries"
(p "When an island's rendering or effect throws, the error currently propagates to the top level and may crash other islands. An error boundary catches the error and renders a fallback UI.")
(~doc-subsection :title "Design"
(~doc-code :code (highlight ";; error-boundary — catch errors in island subtrees\n(defisland ~resilient-widget ()\n (error-boundary\n ;; Fallback: shown when children throw\n (fn (err)\n (div :class \"p-4 bg-red-50 border border-red-200 rounded\"\n (p :class \"text-red-700 font-medium\" \"Something went wrong\")\n (p :class \"text-red-500 text-sm\" (error-message err))))\n ;; Children: the happy path\n (do\n (~risky-component)\n (~another-component))))" "lisp"))
(p "Implementation:")
(ol :class "space-y-1 text-stone-600 list-decimal list-inside text-sm"
(li (code "error-boundary") " is a new render-dom form")
(li "First arg: fallback function " (code "(fn (error) ...)") " that returns DOM")
(li "Remaining args: children rendered inside a try/catch")
(li "On error: clear the boundary container, render fallback with the caught error")
(li "Effects within the boundary are disposed on error")
(li "A " (code "retry") " function is passed to the fallback for recovery"))))
(~doc-section :title "P2: Suspense" :id "suspense"
(p "Suspense handles async operations in the render path — data fetching, lazy-loaded components, code splitting. Show a loading placeholder until the async work completes, then swap in the result.")
(~doc-subsection :title "Design"
(~doc-code :code (highlight ";; suspense — async-aware rendering boundary\n(defisland ~user-profile (&key user-id)\n (suspense\n ;; Fallback: shown during loading\n (div :class \"animate-pulse\"\n (div :class \"h-4 bg-stone-200 rounded w-3/4\")\n (div :class \"h-4 bg-stone-200 rounded w-1/2 mt-2\"))\n ;; Children: may contain async operations\n (let ((user (await (fetch-json (str \"/api/users/\" user-id)))))\n (div\n (h2 (get user \"name\"))\n (p (get user \"email\"))))))" "lisp"))
(p "This requires a new primitive concept: a " (strong "resource") " — an async signal that transitions through loading → resolved → error states.")
(~doc-code :code (highlight ";; resource — async signal\n(define resource\n (fn (fetch-fn)\n ;; Returns a signal-like value:\n ;; {:loading true :data nil :error nil} initially\n ;; {:loading false :data result :error nil} on success\n ;; {:loading false :data nil :error err} on failure\n (let ((state (signal (dict \"loading\" true\n \"data\" nil\n \"error\" nil))))\n ;; Kick off the async operation\n (promise-then (fetch-fn)\n (fn (data) (reset! state (dict \"loading\" false\n \"data\" data\n \"error\" nil)))\n (fn (err) (reset! state (dict \"loading\" false\n \"data\" nil\n \"error\" err))))\n state)))" "lisp"))
(p "Suspense is the rendering boundary; resource is the data primitive. Together they give a clean async data story without effects-that-fetch (React's " (code "useEffect") " + " (code "useState") " anti-pattern).")))
(~doc-section :title "P2: Transitions" :id "transitions"
(p "Transitions mark updates as non-urgent. The UI stays interactive during expensive re-renders. React's " (code "startTransition") " defers state updates so that urgent updates (typing, clicking) aren't blocked by slow ones (filtering a large list, rendering a complex subtree).")
(~doc-subsection :title "Design"
(~doc-code :code (highlight ";; transition — non-urgent signal update\n(defisland ~search-results (&key items)\n (let ((query (signal \"\"))\n (filtered (signal items))\n (is-pending (signal false)))\n ;; Typing is urgent — updates immediately\n ;; Filtering is deferred — doesn't block input\n (effect (fn ()\n (let ((q (deref query)))\n (transition is-pending\n (fn ()\n (reset! filtered\n (filter (fn (item)\n (contains? (lower (get item \"name\")) (lower q)))\n items)))))))\n (div\n (input :bind query :placeholder \"Search...\")\n (when (deref is-pending)\n (span :class \"text-stone-400\" \"Filtering...\"))\n (ul (map (fn (item) (li (get item \"name\")))\n (deref filtered))))))" "lisp"))
(p (code "transition") " takes a pending-signal and a thunk. It sets pending to true, schedules the thunk via " (code "requestIdleCallback") " (or " (code "setTimeout 0") " as fallback), then sets pending to false when complete. Signal writes inside the thunk are batched and applied asynchronously.")
(p "This is lower priority because SX's fine-grained updates already avoid the re-render-everything problem that makes transitions critical in React. But for truly large lists or expensive computations, deferral is still valuable.")))
;; -----------------------------------------------------------------------
;; Implementation order
;; -----------------------------------------------------------------------
(~doc-section :title "Implementation Order" :id "order"
(p "Each feature is independent. Suggested order based on dependency and value:")
(ol :class "space-y-3 text-stone-600 list-decimal list-inside"
(li (strong "Input binding") " (P0) — unlocks forms. Smallest change, biggest impact. One new function in adapter-dom.sx, two platform primitives (" (code "dom-set-prop") ", " (code "dom-get-prop") "). Add to demo page immediately.")
(li (strong "Keyed reconciliation") " (P0) — unlocks efficient dynamic lists. Replace reactive-list's effect body. Add " (code ":key") " extraction. No new primitives needed.")
(li (strong "Refs") " (P1) — trivial: three functions in signals.sx + one attr handler. Unlocks canvas, focus management, animation frame patterns.")
(li (strong "Portals") " (P1) — one new render-dom form. Needs disposal integration. Unlocks modals, tooltips, toasts.")
(li (strong "Error boundaries") " (P2) — one new render-dom form with try/catch. Independent of everything else.")
(li (strong "Suspense + resource") " (P2) — new signal variant + render-dom form. Needs promise platform primitives. Builds on error boundaries for the error case.")
(li (strong "Transitions") " (P2) — scheduling primitive + signal batching variant. Lowest priority — SX's fine-grained model already avoids most jank."))
(p :class "mt-4 text-stone-600" "Every feature follows the same pattern: spec in " (code ".sx") " → bootstrap to JS/Python → add platform primitives → add demo island. No feature requires changes to the signal runtime, the evaluator, or the rendering pipeline. They are all additive."))
(~doc-section :title "What we are NOT building" :id "not-building"
(p "Some React features are deliberately excluded:")
(ul :class "space-y-2 text-stone-600 list-disc pl-5"
(li (strong "Virtual DOM / diffing") " — SX uses fine-grained signals. There is no component re-render to diff against. The " (code "reactive-text") ", " (code "reactive-attr") ", " (code "reactive-fragment") ", and " (code "reactive-list") " primitives update the exact DOM nodes that changed.")
(li (strong "JSX / template compilation") " — SX is interpreted at runtime. No build step. The s-expression syntax " (em "is") " the component tree — there is nothing to compile.")
(li (strong "Server components (React-style)") " — SX already has a richer version. The " (code "aser") " mode evaluates server-side logic and serializes the result as SX wire format. Components can be expanded on the server or deferred to the client. This is more flexible than React's server/client component split.")
(li (strong "Concurrent rendering / fiber") " — React's fiber architecture exists to time-slice component re-renders. SX has no component re-renders to slice. Fine-grained updates are inherently incremental.")
(li (strong "Hooks rules") " — Signals are values, not hooks. No rules about ordering, no conditional creation restrictions, no dependency arrays. This is a feature, not a gap.")))))