Add Reactive Islands plan to sx-docs

Design plan for client-side state via signals and islands — a second
sliding bar (reactivity) orthogonal to the existing isomorphism bar.
Covers signal primitives, defisland, shared state, reactive DOM
rendering, SSR hydration, and spec architecture.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-08 01:44:58 +00:00
parent dba5bf05fa
commit b2aaa3786d
3 changed files with 261 additions and 1 deletions

View File

@@ -157,7 +157,9 @@
(dict :label "SX CI Pipeline" :href "/plans/sx-ci"
:summary "Build, test, and deploy in s-expressions — CI pipelines as SX components.")
(dict :label "Live Streaming" :href "/plans/live-streaming"
:summary "SSE and WebSocket transports for re-resolving suspense slots after initial page load — live data, real-time collaboration.")))
:summary "SSE and WebSocket transports for re-resolving suspense slots after initial page load — live data, real-time collaboration.")
(dict :label "Reactive Islands" :href "/plans/reactive-islands"
:summary "Client-side state via signals and islands — a sliding bar between hypermedia and React, orthogonal to the server/client rendering bar.")))
(define bootstrappers-nav-items (list
(dict :label "Overview" :href "/bootstrappers/")

View File

@@ -2465,3 +2465,260 @@
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/sx/ref/boundary.sx")
(td :class "px-3 py-2 text-stone-700" "SSE/WS IO primitive declarations")))))))
;; ---------------------------------------------------------------------------
;; Reactive Islands: Client State via Signals
;; ---------------------------------------------------------------------------
(defcomp ~plan-reactive-islands-content ()
(~doc-page :title "Reactive Islands"
;; -----------------------------------------------------------------------
;; Context
;; -----------------------------------------------------------------------
(~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."))
;; -----------------------------------------------------------------------
;; The Spectrum
;; -----------------------------------------------------------------------
(~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. This is the core of this plan."))
(~doc-subsection :title "Level 3: Connected Islands"
(p "Islands that share state. Pass the same signal to multiple islands and they stay synchronized. Or use a named store for islands that are distant in the DOM tree. This is where SX starts to feel like React — but only in the regions that need it. The surrounding page remains hypermedia.")))
;; -----------------------------------------------------------------------
;; Signals
;; -----------------------------------------------------------------------
(~doc-section :title "Signals" :id "signals"
(p "The core primitive. A signal is a container for a value that notifies subscribers when it changes. Signals are first-class values — they can be created anywhere, passed as arguments, stored in dicts, shared between islands.")
(~doc-code :code (highlight ";; Create a signal with an initial value\n(define count (signal 0))\n\n;; Read the current value — subscribes the current reactive context\n(deref count) ;; → 0\n\n;; Write a new value — notifies all subscribers\n(reset! count 5) ;; count is now 5\n\n;; Update via function\n(swap! count inc) ;; count is now 6\n\n;; Derived signal — recomputes when dependencies change\n(define doubled (computed (fn () (* 2 (deref count)))))\n(deref doubled) ;; → 12, auto-updates when count changes\n\n;; Side effect — runs when dependencies change\n(effect (fn ()\n (log (str \"Count is now: \" (deref count)))))" "lisp"))
(~doc-subsection :title "Why signals, not useState"
(p (code "useState") " ties state to a component instance. Signals are independent values. This matters:")
(ul :class "space-y-2 text-stone-600"
(li (strong "No positional hooks.") " Signals can be created in conditionals, loops, closures. No rules of hooks.")
(li (strong "Fine-grained updates.") " A signal change updates only the DOM nodes that read it — not the entire component tree. No virtual DOM diffing.")
(li (strong "Shareable.") " Pass a signal to another island and both stay synchronized. No Context, no Provider, no useSelector.")
(li (strong "Composable.") " " (code "computed") " derives new signals from existing ones. The dependency graph builds itself.")))
(~doc-subsection :title "Reactive context"
(p "Inside an island's rendering, " (code "deref") " subscribes the current DOM node to the signal. Outside an island (server render, static component), " (code "deref") " just returns the current value — no subscription, no overhead. The reactive context is the island boundary.")
(p "This means the same component code works in both contexts. " (code "(span (deref count))") " renders " (code "<span>0</span>") " on the server and creates a reactive text binding on the client.")))
;; -----------------------------------------------------------------------
;; Islands
;; -----------------------------------------------------------------------
(~doc-section :title "Islands" :id "islands"
(p (code "defisland") " is like " (code "defcomp") " but creates a reactive boundary. Inside an island, signals are tracked. Outside, everything is static hypermedia.")
(~doc-code :code (highlight "(defisland ~counter (&key initial)\n (let ((count (signal (or initial 0))))\n (div :class \"counter\"\n (span :class \"text-2xl font-bold\" (deref count))\n (div :class \"flex gap-2 mt-2\"\n (button :on-click (fn (e) (swap! count inc)) \"+\")\n (button :on-click (fn (e) (swap! count dec)) \"-\")))))" "lisp"))
(p "The island is self-contained. " (code "count") " is local state. The buttons modify it. The span updates. Nothing outside the island is affected. No server round-trip.")
(~doc-subsection :title "Island lifecycle"
(ol :class "space-y-2 text-stone-600 list-decimal list-inside"
(li (strong "Definition: ") (code "defisland") " registers a reactive component in the environment, same as " (code "defcomp") " but with an island flag.")
(li (strong "Server render: ") "Server evaluates the island with initial values. " (code "deref") " returns initial value. Output is HTML with " (code "data-sx-island") " and " (code "data-sx-state") " attributes.")
(li (strong "Hydration: ") "Client finds " (code "data-sx-island") " elements. Creates signals from serialized state. Re-renders the island body in a reactive context. Morphs existing DOM to preserve structure.")
(li (strong "Updates: ") "User interaction triggers signal changes. Subscribed DOM nodes update directly. No re-render of the full island — just the affected nodes.")
(li (strong "Disposal: ") "When the island is removed from the DOM (navigation, swap), all signals and effects created within it are cleaned up.")))
(~doc-subsection :title "Hydration protocol"
(p "The server renders an island as static HTML annotated with state:")
(~doc-code :code (highlight ";; Server output:\n<div data-sx-island=\"counter\" data-sx-state='{\"count\": 0}'>\n <span class=\"text-2xl font-bold\">0</span>\n <div class=\"flex gap-2 mt-2\">\n <button>+</button>\n <button>-</button>\n </div>\n</div>\n\n;; Client hydration:\n;; 1. Find data-sx-island elements\n;; 2. Parse data-sx-state into signals\n;; 3. Re-render island body in reactive context\n;; 4. Morph against existing DOM (preserves focus, scroll, etc.)" "html"))))
;; -----------------------------------------------------------------------
;; Connecting Islands
;; -----------------------------------------------------------------------
(~doc-section :title "Connecting Islands" :id "connecting"
(p "Islands are isolated by default. Connect them by sharing signals.")
(~doc-subsection :title "Shared signals via props"
(p "The simplest pattern: create a signal outside the islands, pass it to both.")
(~doc-code :code (highlight ";; Page-level: create shared state, pass to multiple islands\n(let ((count (signal 0)))\n (<>\n ;; These two islands share the same count signal\n (~counter-controls :count count)\n (div :class \"my-8\"\n (p \"This is static hypermedia content between the islands.\"))\n (~counter-display :count count)))\n\n(defisland ~counter-controls (&key count)\n (div :class \"flex gap-2\"\n (button :on-click (fn (e) (swap! count inc)) \"+\")\n (button :on-click (fn (e) (swap! count dec)) \"-\")))\n\n(defisland ~counter-display (&key count)\n (div :class \"text-4xl font-bold\" (deref count)))" "lisp"))
(p "Click \"+\" in " (code "~counter-controls") " and " (code "~counter-display") " updates instantly. No server. No event bus. Just a shared reference."))
(~doc-subsection :title "Named stores"
(p "For islands that are distant in the DOM tree — or defined in different .sx files — passing props is impractical. Named stores provide ambient shared state:")
(~doc-code :code (highlight ";; Declare a named store (page-level)\n(def-store cart\n {:items (signal (list))\n :count (computed (fn () (length (deref items))))\n :total (computed (fn () (reduce + 0\n (map (fn (i) (get i \"price\")) (deref items)))))})\n\n;; Any island can subscribe by name\n(defisland ~cart-badge ()\n (let ((s (use-store cart)))\n (span :class \"badge\" (deref (get s \"count\")))))\n\n(defisland ~cart-drawer ()\n (let ((s (use-store cart)))\n (ul (map (fn (item)\n (li (get item \"name\") \" — £\" (get item \"price\")))\n (deref (get s \"items\"))))))" "lisp"))
(p "Stores are created once per page. " (code "use-store") " returns the store's signal dict. Multiple islands reading the same store share reactive state without prop threading."))
(~doc-subsection :title "Mixing with hypermedia"
(p "Islands coexist with server-driven content. A single page can have:")
(ul :class "space-y-2 text-stone-600"
(li "A navigation bar rendered by the server via " (code "sx-get") " / OOB swap")
(li "A product list rendered server-side with " (code "sx-swap"))
(li "An \"Add to Cart\" island with local quantity state and optimistic update")
(li "A cart badge island connected to the same cart store")
(li "A checkout form that submits via " (code "sx-post") " — back to server"))
(p "The server handles auth, data, routing. Islands handle interactions that need instant feedback. This is not a compromise — it is the architecture.")))
;; -----------------------------------------------------------------------
;; Reactive DOM Rendering
;; -----------------------------------------------------------------------
(~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"
(p "When " (code "renderDOM") " encounters " (code "(deref sig)") " in a text position, it creates a text node and subscribes it to the signal:")
(~doc-code :code (highlight ";; Source\n(span (deref count))\n\n;; renderDOM creates:\n;; const span = document.createElement('span')\n;; const text = document.createTextNode(sig.value)\n;; effect(() => text.nodeValue = sig.value)\n;; span.appendChild(text)" "lisp"))
(p "When " (code "count") " changes, only the text node updates. The span is untouched. No diffing."))
(~doc-subsection :title "Attribute bindings"
(p "Signal reads in attribute values create reactive attribute bindings:")
(~doc-code :code (highlight ";; Source\n(div :class (str \"panel \" (if (deref open?) \"visible\" \"hidden\")))\n\n;; renderDOM creates the div, then:\n;; effect(() => div.className = \"panel \" + (sig.value ? \"visible\" : \"hidden\"))" "lisp")))
(~doc-subsection :title "Conditional fragments"
(p "When a signal appears inside control flow (" (code "if") ", " (code "when") ", " (code "cond") "), the entire branch becomes a reactive fragment:")
(~doc-code :code (highlight ";; Source\n(when (deref show-details?)\n (~product-details :product product))\n\n;; renderDOM creates a marker node, then:\n;; effect(() => {\n;; if (sig.value)\n;; insert rendered fragment after marker\n;; else\n;; remove fragment\n;; })" "lisp"))
(p "This is equivalent to SolidJS's " (code "Show") " component — but it falls out naturally from the evaluator. No special component needed."))
(~doc-subsection :title "List rendering"
(p "Reactive lists use keyed reconciliation:")
(~doc-code :code (highlight "(map (fn (item)\n (li :key (get item \"id\")\n (get item \"name\")))\n (deref items))" "lisp"))
(p "When " (code "items") " changes, the " (code "map") " re-runs. Keyed elements are reused and reordered. Unkeyed elements are morphed. This reuses the existing morph algorithm from SxEngine.")))
;; -----------------------------------------------------------------------
;; Server Integration
;; -----------------------------------------------------------------------
(~doc-section :title "Server Integration" :id "server"
(p "Islands are server-renderable. The server evaluates the island body with initial signal values. " (code "deref") " returns the value, " (code "reset!") " and " (code "swap!") " are no-ops. The output is static HTML annotated for client hydration.")
(~doc-subsection :title "SSR render mode"
(p "The Python evaluator already handles " (code "defcomp") ". For " (code "defisland") ", it additionally:")
(ol :class "space-y-1 text-stone-600 list-decimal list-inside"
(li "Tracks signals created during body evaluation")
(li "Wraps output in a " (code "data-sx-island") " container")
(li "Serializes signal initial values to " (code "data-sx-state"))
(li "Renders the body as static HTML (no subscriptions)")))
(~doc-subsection :title "Server-to-island updates"
(p "The server can update island state via the existing OOB swap mechanism. A handler response can include " (code "sx-swap-oob") " targeting an island's container, providing new state:")
(~doc-code :code (highlight ";; Server handler response includes:\n(div :id \"cart-island\" :sx-swap-oob \"cart-island\"\n :data-sx-state '{\"items\": [{\"name\": \"Widget\", \"price\": 9.99}]}')\n\n;; Client: receives OOB swap, re-hydrates island with new state" "lisp"))
(p "This bridges server and client state. The server pushes updates; the island incorporates them into its signal graph. Both directions work.")))
;; -----------------------------------------------------------------------
;; Spec Architecture
;; -----------------------------------------------------------------------
(~doc-section :title "Spec Architecture" :id "spec"
(p "Following the SX-first principle: signal semantics are defined in " (code ".sx") " spec files, then bootstrapped to JavaScript (and eventually Python for SSR).")
(~doc-subsection :title "signals.sx — the signal runtime"
(p "New spec file defining:")
(ul :class "space-y-1 text-stone-600 list-disc pl-5"
(li (code "signal") " — create a signal container")
(li (code "computed") " — derived signal with automatic dependency tracking")
(li (code "effect") " — side effect that runs when dependencies change, returns cleanup handle")
(li (code "deref") " — read signal value (subscribes in reactive context)")
(li (code "reset!") " — write signal value (notifies subscribers)")
(li (code "swap!") " — update signal via function")
(li (code "batch") " — group multiple signal writes into one notification pass")
(li (code "dispose") " — tear down an effect or computed")
(li "Dependency tracking algorithm: during effect/computed evaluation, " (code "deref") " calls register the signal as a dependency. Re-evaluation clears old deps and rebuilds."))
(p "Signals are pure computation — no DOM, no IO. The spec is host-agnostic."))
(~doc-subsection :title "Changes to existing specs"
(ul :class "space-y-2 text-stone-600 list-disc pl-5"
(li (strong "eval.sx: ") (code "defisland") " special form — like " (code "defcomp") " but sets an island flag on the component.")
(li (strong "adapter-dom.sx: ") "Reactive " (code "renderDOM") " mode — when inside an island context, " (code "deref") " creates subscriptions and " (code "renderList") " wraps control flow in effects.")
(li (strong "adapter-html.sx: ") "SSR rendering of islands — wraps output in " (code "data-sx-island") " container, serializes signal state.")
(li (strong "boundary.sx: ") "Signal primitives declaration — " (code "signal") ", " (code "computed") ", " (code "effect") " as pure primitives; " (code "def-store") ", " (code "use-store") " as page helpers.")))
(~doc-subsection :title "Bootstrap"
(p (code "bootstrap_js.py") " generates the signal runtime into " (code "sx.js") ". The reactive rendering extensions fold into the existing " (code "renderDOM") " function. No separate file — signals are part of the core evaluator.")
(p (code "bootstrap_py.py") " generates SSR support: " (code "defisland") " handling, " (code "deref") " as plain read, state serialization. The Python side never tracks subscriptions — it only renders the initial state.")))
;; -----------------------------------------------------------------------
;; Files
;; -----------------------------------------------------------------------
(~doc-section :title "Files" :id "files"
(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" "File")
(th :class "px-3 py-2 font-medium text-stone-600" "Change")))
(tbody
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/sx/ref/signals.sx")
(td :class "px-3 py-2 text-stone-700" "New — signal runtime spec: signal, computed, effect, deref, reset!, swap!, batch, dispose, dependency tracking"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/sx/ref/eval.sx")
(td :class "px-3 py-2 text-stone-700" "defisland special form, def-store, use-store"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/sx/ref/adapter-dom.sx")
(td :class "px-3 py-2 text-stone-700" "Reactive renderDOM — signal-aware text/attribute/fragment bindings"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/sx/ref/adapter-html.sx")
(td :class "px-3 py-2 text-stone-700" "SSR island rendering — data-sx-island wrapper, state serialization"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/sx/ref/boundary.sx")
(td :class "px-3 py-2 text-stone-700" "Signal and store primitive declarations"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/sx/ref/bootstrap_js.py")
(td :class "px-3 py-2 text-stone-700" "Transpile signals.sx into sx.js"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/sx/ref/bootstrap_py.py")
(td :class "px-3 py-2 text-stone-700" "Transpile island SSR support into sx_ref.py"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/static/scripts/sx.js")
(td :class "px-3 py-2 text-stone-700" "Rebootstrap — signal runtime + reactive rendering"))
(tr :class "border-b border-stone-100"
(td :class "px-3 py-2 font-mono text-sm text-violet-700" "shared/sx/ref/sx_ref.py")
(td :class "px-3 py-2 text-stone-700" "Rebootstrap — island SSR"))))))
;; -----------------------------------------------------------------------
;; Design Principles
;; -----------------------------------------------------------------------
(~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. This is slower than SolidJS's compiled output. For islands — small, focused interactive regions — it does not matter."))
(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."))))

View File

@@ -581,6 +581,7 @@
"social-sharing" (~plan-social-sharing-content)
"sx-ci" (~plan-sx-ci-content)
"live-streaming" (~plan-live-streaming-content)
"reactive-islands" (~plan-reactive-islands-content)
:else (~plans-index-content)))
;; ---------------------------------------------------------------------------