- signals.sx: fix has? → has-key?, add def-store/use-store/clear-stores (L3 named stores), emit-event/on-event/bridge-event (event bridge) - boot.sx: add sx-hydrate-islands, hydrate-island, dispose-island for client-side island hydration from SSR output - bootstrap_js.py: add RENAMES, platform fns (domListen, eventDetail, domGetData, jsonParse), public API exports for all new functions - bootstrap_py.py: add RENAMES, server-side no-op stubs for DOM events - Regenerate sx-ref.js (with boot adapter) and sx_ref.py - Update reactive-islands status: hydration, stores, bridge all spec'd Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
412 lines
39 KiB
Plaintext
412 lines
39 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-amber-600 font-medium" "TODO")
|
|
(td :class "px-3 py-2 font-mono text-xs text-stone-500" ":on-click wiring"))
|
|
(tr
|
|
(td :class "px-3 py-2 text-stone-700" "Keyed list reconciliation")
|
|
(td :class "px-3 py-2 text-amber-600 font-medium" "TODO")
|
|
(td :class "px-3 py-2 font-mono text-xs text-stone-500" "reactive-list morph"))))))))
|
|
|
|
|
|
;; ---------------------------------------------------------------------------
|
|
;; 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 "Everything below runs on signal primitives " (strong "transpiled from the SX spec") ". The signal runtime is defined in " (code "signals.sx") " (291 lines of s-expressions), then bootstrapped to JavaScript by " (code "bootstrap_js.py") ". No hand-written signal logic in JavaScript.")
|
|
(p "The transpiled " (code "sx-ref.js") " exports " (code "Sx.signal") ", " (code "Sx.deref") ", " (code "Sx.reset") ", " (code "Sx.swap") ", " (code "Sx.computed") ", " (code "Sx.effect") ", and " (code "Sx.batch") " — all generated from the spec."))
|
|
|
|
(~doc-section :title "1. Signal + Computed + Effect" :id "demo-counter"
|
|
(p "A signal holds a value. A computed derives from it. Effects subscribe to both and update the DOM when either changes.")
|
|
(~doc-code :code (highlight "(define count (signal 0))\n(define doubled (computed (fn () (* 2 (deref count)))))\n\n;; Effect subscribes to count, updates DOM\n(effect (fn ()\n (dom-set-text-content display (deref count))))\n\n;; Effect subscribes to doubled, updates DOM\n(effect (fn ()\n (dom-set-text-content doubled-display (str \"doubled: \" (deref doubled)))))\n\n;; swap! updates count, both effects re-run\n(swap! count inc) ;; display shows 1, doubled-display shows \"doubled: 2\"" "lisp"))
|
|
(p "The counter increments. The doubled value updates automatically. Each effect only re-runs when its specific dependencies change. No virtual DOM. No diffing."))
|
|
|
|
(~doc-section :title "2. Batch" :id "demo-batch"
|
|
(p "Without batch, two signal writes trigger two effect runs. With batch, writes are deferred and subscribers notified once at the end.")
|
|
(~doc-code :code (highlight ";; Without batch: 2 writes = 2 effect runs\n(reset! first 1) ;; effect runs\n(reset! second 2) ;; effect runs again\n\n;; With batch: 2 writes = 1 effect run\n(batch (fn ()\n (reset! first 1)\n (reset! second 2))) ;; effect runs once" "lisp"))
|
|
(p "Batch deduplicates subscribers across all queued signals. If two signals notify the same effect, it runs once, not twice."))
|
|
|
|
(~doc-section :title "3. Effect with cleanup" :id "demo-effect"
|
|
(p "An effect can return a cleanup function. The cleanup runs before the effect re-runs (when dependencies change) and when the effect is disposed.")
|
|
(~doc-code :code (highlight "(effect (fn ()\n (let ((active (deref polling)))\n (when active\n (let ((id (set-interval poll-fn 500)))\n ;; Return cleanup — runs before next re-run or on dispose\n (fn () (clear-interval id)))))))" "lisp"))
|
|
(p "This mirrors React's " (code "useEffect") " cleanup pattern, but without the hook rules. The effect can be created anywhere — in a conditional, in a loop, in a closure."))
|
|
|
|
(~doc-section :title "4. Computed chains" :id "demo-chain"
|
|
(p "Computed signals can depend on other computed signals. The dependency graph builds itself via " (code "deref") " calls during evaluation.")
|
|
(~doc-code :code (highlight "(define base (signal 1))\n(define doubled (computed (fn () (* 2 (deref base)))))\n(define quadrupled (computed (fn () (* 2 (deref doubled)))))\n\n;; Change base, both derived signals update\n(reset! base 3)\n(deref quadrupled) ;; => 12" "lisp"))
|
|
(p "Three-level dependency chain. When " (code "base") " changes, " (code "doubled") " recomputes, which triggers " (code "quadrupled") " to recompute. Each computed only recomputes if its actual value changed — " (code "identical?") " check prevents unnecessary propagation."))
|
|
|
|
(~doc-section :title "5. defisland" :id "demo-island"
|
|
(p (code "defisland") " creates a reactive component. Same calling convention as " (code "defcomp") " — keyword args, rest children — but with an island flag that triggers reactive rendering.")
|
|
(~doc-code :code (highlight "(defisland ~counter (&key initial)\n (let ((count (signal (or initial 0))))\n (div :class \"counter\"\n (span :class \"text-2xl font-bold\" (deref count))\n (div :class \"flex gap-2 mt-2\"\n (button :on-click (fn (e) (swap! count inc)) \"+\")\n (button :on-click (fn (e) (swap! count dec)) \"-\")))))\n\n;; Server renders static HTML:\n;; <div data-sx-island=\"counter\" data-sx-state='{\"initial\": 0}'>\n;; <span class=\"text-2xl font-bold\">0</span>\n;; <div class=\"flex gap-2 mt-2\">\n;; <button>+</button> <button>-</button>\n;; </div>\n;; </div>" "lisp"))
|
|
(p "The island is self-contained. " (code "count") " is local state. Buttons modify it. The span updates. Nothing outside the island is affected. No server round-trip."))
|
|
|
|
(~doc-section :title "6. Test suite" :id "demo-tests"
|
|
(p "17 tests verify the signal runtime against the spec. All pass in the Python test runner (which uses the hand-written evaluator with native platform primitives).")
|
|
(~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 "The spec layer and bootstrappers are complete. Remaining work:")
|
|
(ul :class "space-y-2 text-stone-600 list-disc pl-5"
|
|
(li (strong "Client hydration") " — " (code "boot.sx") " discovers " (code "data-sx-island") " elements, creates signals from " (code "data-sx-state") ", re-renders in reactive context")
|
|
(li (strong "Event bindings") " — wire " (code ":on-click (fn (e) ...)") " inside islands to DOM event listeners")
|
|
(li (strong "Keyed list reconciliation") " — " (code "reactive-list") " currently clears and re-renders; needs keyed morph for efficient updates"))
|
|
(p "See the " (a :href "/reactive-islands/plan" :sx-get "/reactive-islands/plan" :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true" :class "text-violet-700 underline" "full plan") " for the complete design document."))))
|
|
|
|
|
|
;; ---------------------------------------------------------------------------
|
|
;; 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 "Remaining Work" :id "remaining"
|
|
(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" "File")
|
|
(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" "Client hydration")
|
|
(td :class "px-3 py-2 font-mono text-xs text-stone-500" "boot.sx")
|
|
(td :class "px-3 py-2 text-stone-700" "Discover data-sx-island elements, create signals from data-sx-state, re-render in reactive context"))
|
|
(tr :class "border-b border-stone-100"
|
|
(td :class "px-3 py-2 text-stone-700" "Event bindings")
|
|
(td :class "px-3 py-2 font-mono text-xs text-stone-500" "adapter-dom.sx")
|
|
(td :class "px-3 py-2 text-stone-700" "Wire :on-click (fn (e) ...) inside islands to DOM addEventListener"))
|
|
(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 font-mono text-xs text-stone-500" "orchestration.sx")
|
|
(td :class "px-3 py-2 text-stone-700" "Auto-dispatch custom events from data-sx-emit attributes on click"))
|
|
(tr :class "border-b border-stone-100"
|
|
(td :class "px-3 py-2 text-stone-700" "Store bootstrapping")
|
|
(td :class "px-3 py-2 font-mono text-xs text-stone-500" "bootstrap_js.py")
|
|
(td :class "px-3 py-2 text-stone-700" "Transpile def-store, use-store, clear-stores, bridge-event to JS"))
|
|
(tr :class "border-b border-stone-100"
|
|
(td :class "px-3 py-2 text-stone-700" "Keyed list reconciliation")
|
|
(td :class "px-3 py-2 font-mono text-xs text-stone-500" "adapter-dom.sx")
|
|
(td :class "px-3 py-2 text-stone-700" "Keyed morph for reactive-list instead of clear + re-render"))
|
|
(tr
|
|
(td :class "px-3 py-2 text-stone-700" "Navigation integration")
|
|
(td :class "px-3 py-2 font-mono text-xs text-stone-500" "orchestration.sx")
|
|
(td :class "px-3 py-2 text-stone-700" "Call clear-stores on full page navigation, preserve stores on partial swaps"))))))
|
|
|
|
(~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."))))
|