- 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>
899 lines
83 KiB
Plaintext
899 lines
83 KiB
Plaintext
;; 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.")))))
|