Fix lambda multi-body, reactive island demos, and add React is Hypermedia essay
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
Lambda multi-body fix: sf-lambda used (nth args 1), dropping all but the first body expression. Fixed to collect all body expressions and wrap in (begin ...). This was foundational — every multi-expression lambda in every island silently dropped expressions after the first. Reactive islands: fix dom-parent marker timing (first effect run before marker is in DOM), fix :key eager evaluation, fix error boundary scope isolation, fix resource/suspense reactive cond tracking, fix inc not available as JS var. New essay: "React is Hypermedia" — argues that reactive islands are hypermedia controls whose behavior is specified in SX, not a departure from hypermedia. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1143,3 +1143,90 @@
|
||||
"No build step. No bundler. No transpiler. No package manager. No CSS preprocessor. No dev server. No linter. No formatter. No type checker. No framework CLI. No code editor.")
|
||||
(p :class "text-stone-600"
|
||||
"Zero tools."))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; React is Hypermedia
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
(defcomp ~essay-react-is-hypermedia ()
|
||||
(~doc-page :title "React is Hypermedia"
|
||||
(p :class "text-stone-500 text-sm italic mb-8"
|
||||
"A React Island is a hypermedia control. Its behavior is specified in SX.")
|
||||
(~doc-section :title "I. The argument" :id "argument"
|
||||
(p :class "text-stone-600"
|
||||
"React is not hypermedia. Everyone knows this. React is a JavaScript UI library. It renders components to a virtual DOM. It diffs. It patches. It manages state. It does none of the things that define " (a :href "https://en.wikipedia.org/wiki/Hypermedia" :class "text-violet-600 hover:underline" "hypermedia") " — server-driven content, links as the primary interaction mechanism, representations that carry their own controls.")
|
||||
(p :class "text-stone-600"
|
||||
"And yet. Consider what a React Island actually is:")
|
||||
(ul :class "list-disc pl-6 space-y-1 text-stone-600"
|
||||
(li "It is embedded in a server-rendered page.")
|
||||
(li "Its initial content is delivered as HTML (or as serialised SX, which the client renders to DOM).")
|
||||
(li "It occupies a region of the page — a bounded area with a defined boundary.")
|
||||
(li "It responds to user interaction by mutating its own DOM.")
|
||||
(li "It does not fetch data. It does not route. It does not manage application state outside its boundary."))
|
||||
(p :class "text-stone-600"
|
||||
"This is a " (a :href "https://en.wikipedia.org/wiki/Hypermedia#Controls" :class "text-violet-600 hover:underline" "hypermedia control") ". It is a region of a hypermedia document that responds to user input. Like a " (code "<form>") ". Like an " (code "<a>") ". Like an " (code "<input>") ". The difference is that a form's behavior is specified by the browser and the HTTP protocol. An island's behavior is specified in SX."))
|
||||
(~doc-section :title "II. What makes something hypermedia" :id "hypermedia"
|
||||
(p :class "text-stone-600"
|
||||
"Roy " (a :href "https://en.wikipedia.org/wiki/Roy_Fielding" :class "text-violet-600 hover:underline" "Fielding") "'s " (a :href "https://en.wikipedia.org/wiki/Representational_state_transfer" :class "text-violet-600 hover:underline" "REST") " thesis defines hypermedia by a constraint: " (em "hypermedia as the engine of application state") " (HATEOAS). The server sends representations that include controls — links, forms — and the client's state transitions are driven by those controls. The client does not need out-of-band knowledge of what actions are available. The representation " (em "is") " the interface.")
|
||||
(p :class "text-stone-600"
|
||||
"A traditional SPA violates this. The client has its own router, its own state machine, its own API client that knows the server's URL structure. The HTML is a shell; the actual interface is constructed from JavaScript and API calls. The representation is not the interface — the representation is a loading spinner while the real interface builds itself.")
|
||||
(p :class "text-stone-600"
|
||||
"An SX page does not violate this. The server sends a complete representation — an s-expression tree — that includes all controls. Some controls are plain HTML: " (code "(a :href \"/about\" :sx-get \"/about\")") ". Some controls are reactive islands: " (code "(defisland counter (let ((count (signal 0))) ...))") ". Both are embedded in the representation. Both are delivered by the server. The client does not decide what controls exist — the server does, by including them in the document.")
|
||||
(p :class "text-stone-600"
|
||||
"The island is not separate from the hypermedia. The island " (em "is") " part of the hypermedia. It is a control that the server chose to include, whose behavior the server specified, in the same format as the rest of the page."))
|
||||
(~doc-section :title "III. The SX specification layer" :id "spec-layer"
|
||||
(p :class "text-stone-600"
|
||||
"A " (code "<form>") "'s behavior is specified in HTML + HTTP: " (code "method=\"POST\"") ", " (code "action=\"/submit\"") ". The browser reads the specification and executes it — serialise the inputs, make the request, handle the response. The form does not contain JavaScript. Its behavior is declared.")
|
||||
(p :class "text-stone-600"
|
||||
"An SX island's behavior is specified in SX:")
|
||||
(~doc-code :lang "lisp" :code
|
||||
"(defisland todo-adder\n (let ((text (signal \"\")))\n (form :on-submit (fn (e)\n (prevent-default e)\n (emit-event \"todo:add\" (deref text))\n (reset! text \"\"))\n (input :type \"text\"\n :bind text\n :placeholder \"What needs doing?\")\n (button :type \"submit\" \"Add\"))))")
|
||||
(p :class "text-stone-600"
|
||||
"This is a " (em "declaration") ", not a program. It declares: there is a signal holding text. There is a form. When submitted, it emits an event and resets the signal. There is an input bound to the signal. There is a button.")
|
||||
(p :class "text-stone-600"
|
||||
"The s-expression " (em "is") " the specification. It is not compiled to JavaScript and then executed as an opaque blob. It is parsed, evaluated, and rendered by a transparent evaluator whose own semantics are specified in the same format (" (code "eval.sx") "). The island's behavior is as inspectable as a form's " (code "action") " attribute — you can read it, quote it, transform it, analyse it. You can even send it over the wire and have a different client render it.")
|
||||
(p :class "text-stone-600"
|
||||
"A form says " (em "what to do") " in HTML attributes. An island says " (em "what to do") " in s-expressions. Both are declarative. Both are part of the hypermedia document. The difference is expressiveness: forms can collect inputs and POST them. Islands can maintain local state, compute derived values, animate transitions, handle errors, and render dynamic lists — all declared in the same markup language as the page that contains them."))
|
||||
(~doc-section :title "IV. The four levels" :id "four-levels"
|
||||
(p :class "text-stone-600"
|
||||
"SX reactive islands exist at four levels of complexity, from pure hypermedia to full client reactivity. Each level is a superset of the one before:")
|
||||
(ul :class "list-disc pl-6 space-y-2 text-stone-600"
|
||||
(li (span :class "font-semibold" "L0 — Static server rendering.") " No client interactivity. The server evaluates the full component tree and sends HTML. Pure hypermedia. " (code "(div :class \"card\" (h2 title))") ".")
|
||||
(li (span :class "font-semibold" "L1 — Hypermedia attributes.") " Server-rendered content with htmx-style attributes. " (code "(button :sx-get \"/items\" :sx-target \"#list\")") ". Still server-driven. The client swaps HTML fragments. Classic hypermedia with AJAX.")
|
||||
(li (span :class "font-semibold" "L2 — Reactive islands.") " Self-contained client-side state within a server-rendered page. " (code "(defisland counter ...)") ". The island is a hypermedia control: the server delivers it, the client executes it. Signals, computed values, effects — all inside the island boundary.")
|
||||
(li (span :class "font-semibold" "L3 — Island communication.") " Islands talk to each other and to the htmx-like \"lake\" via DOM events. " (code "(emit-event \"cart:updated\" count)") " and " (code "(on-event \"cart:updated\" handler)") ". Still no global state. Still no client-side routing. The page is still a server document with embedded controls."))
|
||||
(p :class "text-stone-600"
|
||||
"At every level, the architecture is hypermedia. The server produces the document. The document contains controls. The controls are specified in SX. The jump from L1 to L2 is not a jump from hypermedia to SPA — it is a jump from " (em "simple controls") " (links and forms) to " (em "richer controls") " (reactive islands). The paradigm does not change. The expressiveness does."))
|
||||
(~doc-section :title "V. Why not just React?" :id "why-not-react"
|
||||
(p :class "text-stone-600"
|
||||
"If an island behaves like a React component — local state, event handlers, conditional rendering — why not use React?")
|
||||
(p :class "text-stone-600"
|
||||
"Because React requires a " (em "build") ". JSX must be compiled. Modules must be bundled. The result is an opaque JavaScript blob that the server cannot inspect, the wire format cannot represent, and the client must execute before anything is visible. The component's specification — its source code — is lost by the time it reaches the browser.")
|
||||
(p :class "text-stone-600"
|
||||
"An SX island arrives at the browser as source. The same s-expression that defined the island on the server is the s-expression that the client parses and evaluates. There is no compilation, no bundling, no build step. The specification " (em "is") " the artifact.")
|
||||
(p :class "text-stone-600"
|
||||
"This matters because hypermedia's core property is " (em "self-description") ". A hypermedia representation carries its own controls and its own semantics. An HTML form is self-describing: the browser reads the " (code "action") " and " (code "method") " and knows what to do. A compiled React component is not self-describing: it is a function that was once source code, compiled away into instructions that only the React runtime can interpret.")
|
||||
(p :class "text-stone-600"
|
||||
"SX islands are self-describing. The source is the artifact. The representation carries its own semantics. This is what makes them hypermedia controls — not because they avoid JavaScript (they don't), but because the behavior specification travels with the document, in the same format as the document."))
|
||||
(~doc-section :title "VI. The bridge pattern" :id "bridge"
|
||||
(p :class "text-stone-600"
|
||||
"In practice, the hypermedia and the islands coexist through a pattern: the htmx \"lake\" surrounds the reactive \"islands.\" The lake handles navigation, form submission, content loading — classic hypermedia. The islands handle local interaction — counters, toggles, filters, input validation, animations.")
|
||||
(p :class "text-stone-600"
|
||||
"Communication between lake and islands uses DOM events. An island can " (code "emit-event") " to tell the page something happened. A server-rendered button can " (code "bridge-event") " to poke an island when clicked. The DOM — the shared medium — is the only coupling.")
|
||||
(~doc-code :lang "lisp" :code
|
||||
";; Server-rendered lake button dispatches to island\n(button :sx-get \"/api/refresh\"\n :sx-target \"#results\"\n :on-click (bridge-event \"search:clear\")\n \"Reset\")\n\n;; Island listens for the event\n(defisland search-filter\n (let ((query (signal \"\")))\n (on-event \"search:clear\" (fn () (reset! query \"\")))\n (input :bind query :placeholder \"Filter...\")))")
|
||||
(p :class "text-stone-600"
|
||||
"The lake button does its hypermedia thing — fetches HTML, swaps it in. Simultaneously, it dispatches a DOM event. The island hears the event and clears its state. Neither knows about the other's implementation. They communicate through the hypermedia document's event system — the DOM.")
|
||||
(p :class "text-stone-600"
|
||||
"This is not a hybrid architecture bolting two incompatible models together. It is a single model — hypermedia — with controls of varying complexity. Some controls are links. Some are forms. Some are reactive islands. All are specified in the document. All are delivered by the server."))
|
||||
(~doc-section :title "VII. The specification is the specification" :id "specification"
|
||||
(p :class "text-stone-600"
|
||||
"The deepest claim is not architectural but philosophical. A React Island — the kind with signals and effects and computed values — is a " (em "behavior specification") ". It specifies: when this signal changes, recompute this derived value, re-render this DOM subtree. When this event fires, update this state. When this input changes, validate against this pattern.")
|
||||
(p :class "text-stone-600"
|
||||
"In React, this specification is written in JavaScript and destroyed by compilation. The specification exists only in the developer's source file. The user receives a bundle.")
|
||||
(p :class "text-stone-600"
|
||||
"In SX, this specification is written in s-expressions, transmitted as s-expressions, parsed as s-expressions, and evaluated as s-expressions. The specification exists at every stage of the pipeline. It is never destroyed. It is never transformed into something else. It arrives at the browser intact, readable, inspectable.")
|
||||
(p :class "text-stone-600"
|
||||
"And the evaluator that interprets this specification? It is itself specified in s-expressions (" (code "eval.sx") "). And the renderer? Specified in s-expressions (" (code "render.sx") "). And the parser? Specified in s-expressions (" (code "parser.sx") "). The specification language specifies itself. The island's behavior is specified in a language whose behavior is specified in itself.")
|
||||
(p :class "text-stone-600"
|
||||
"A React Island is a hypermedia control. Its behavior is specified in SX. And SX is specified in SX. There is no layer beneath. The specification goes all the way down."))))
|
||||
|
||||
@@ -91,7 +91,9 @@
|
||||
(dict :label "sx sucks" :href "/essays/sx-sucks"
|
||||
:summary "An honest accounting of everything wrong with SX and why you probably shouldn't use it.")
|
||||
(dict :label "Tools for Fools" :href "/essays/zero-tooling"
|
||||
:summary "SX was built without a code editor. No IDE, no build tools, no linters, no bundlers. What zero-tooling web development looks like.")))
|
||||
:summary "SX was built without a code editor. No IDE, no build tools, no linters, no bundlers. What zero-tooling web development looks like.")
|
||||
(dict :label "React is Hypermedia" :href "/essays/react-is-hypermedia"
|
||||
:summary "A React Island is a hypermedia control. Its behavior is specified in SX.")))
|
||||
|
||||
(define philosophy-nav-items (list
|
||||
(dict :label "The SX Manifesto" :href "/philosophy/sx-manifesto"
|
||||
|
||||
@@ -145,19 +145,23 @@
|
||||
(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")))
|
||||
(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 + resource + patterns")))
|
||||
(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")
|
||||
(td :class "px-3 py-2 text-stone-500 font-medium" "N/A")
|
||||
(td :class "px-3 py-2 font-mono text-xs text-stone-500" "covered by existing primitives"))
|
||||
(td :class "px-3 py-2 text-stone-700" "Resource (async signal)")
|
||||
(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: resource, promise-then"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 text-stone-700" "Suspense pattern")
|
||||
(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" "resource + cond/deref (no special form)"))
|
||||
(tr
|
||||
(td :class "px-3 py-2 text-stone-700" "Transitions")
|
||||
(td :class "px-3 py-2 text-stone-500 font-medium" "N/A")
|
||||
(td :class "px-3 py-2 font-mono text-xs text-stone-500" "covered by existing primitives"))))))))
|
||||
(td :class "px-3 py-2 text-stone-700" "Transition pattern")
|
||||
(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" "schedule-idle + batch (no special form)"))))))))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Live demo islands
|
||||
@@ -324,6 +328,160 @@
|
||||
:on-click (fn (e) (reset! open? false))
|
||||
"Close"))))))))
|
||||
|
||||
;; 8. Error boundary — catch errors, render fallback with retry
|
||||
(defisland ~demo-error-boundary ()
|
||||
(let ((throw? (signal false)))
|
||||
(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-red-600 text-white text-sm font-medium hover:bg-red-700"
|
||||
:on-click (fn (e) (reset! throw? true))
|
||||
"Trigger Error")
|
||||
(button :class "px-3 py-1 rounded bg-stone-300 text-stone-700 text-sm hover:bg-stone-400"
|
||||
:on-click (fn (e) (reset! throw? false))
|
||||
"Reset"))
|
||||
(error-boundary
|
||||
;; Fallback: receives (err retry-fn)
|
||||
(fn (err retry-fn)
|
||||
(div :class "p-3 bg-red-50 border border-red-200 rounded"
|
||||
(p :class "text-red-700 font-medium text-sm" "Caught: " (error-message err))
|
||||
(button :class "mt-2 px-3 py-1 rounded bg-red-600 text-white text-sm hover:bg-red-700"
|
||||
:on-click (fn (e) (do (reset! throw? false) (invoke retry-fn)))
|
||||
"Retry")))
|
||||
;; Children: the happy path
|
||||
(do
|
||||
(when (deref throw?)
|
||||
(error "Intentional explosion!"))
|
||||
(p :class "text-sm text-green-700"
|
||||
"Everything is fine. Click \"Trigger Error\" to throw."))))))
|
||||
|
||||
;; 9. Refs — imperative DOM access via :ref attribute
|
||||
(defisland ~demo-refs ()
|
||||
(let ((my-ref (dict "current" nil))
|
||||
(msg (signal "")))
|
||||
(div :class "rounded border border-violet-200 bg-violet-50 p-4 my-4 space-y-3"
|
||||
(input :ref my-ref :type "text" :placeholder "I can be focused programmatically"
|
||||
:class "px-3 py-1.5 rounded border border-stone-300 text-sm focus:outline-none focus:border-violet-400 w-64")
|
||||
(div :class "flex items-center gap-3"
|
||||
(button :class "px-3 py-1 rounded bg-violet-600 text-white text-sm font-medium hover:bg-violet-700"
|
||||
:on-click (fn (e)
|
||||
(dom-focus (get my-ref "current")))
|
||||
"Focus Input")
|
||||
(button :class "px-3 py-1 rounded bg-stone-300 text-stone-700 text-sm hover:bg-stone-400"
|
||||
:on-click (fn (e)
|
||||
(let ((el (get my-ref "current")))
|
||||
(reset! msg (str "Tag: " (dom-tag-name el)
|
||||
", value: \"" (dom-get-prop el "value") "\""))))
|
||||
"Read Input"))
|
||||
(when (not (= (deref msg) ""))
|
||||
(p :class "text-sm text-stone-600 font-mono" (deref msg))))))
|
||||
|
||||
;; 10. Dynamic class/style — computed signals drive class and style reactively
|
||||
(defisland ~demo-dynamic-class ()
|
||||
(let ((danger (signal false))
|
||||
(size (signal 16)))
|
||||
(div :class "rounded border border-violet-200 bg-violet-50 p-4 my-4 space-y-3"
|
||||
(div :class "flex items-center gap-3"
|
||||
(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! danger not))
|
||||
(if (deref danger) "Safe mode" "Danger mode"))
|
||||
(button :class "px-3 py-1 rounded bg-stone-300 text-stone-700 text-sm hover:bg-stone-400"
|
||||
:on-click (fn (e) (swap! size (fn (s) (+ s 2))))
|
||||
"Bigger")
|
||||
(button :class "px-3 py-1 rounded bg-stone-300 text-stone-700 text-sm hover:bg-stone-400"
|
||||
:on-click (fn (e) (swap! size (fn (s) (max 10 (- s 2)))))
|
||||
"Smaller"))
|
||||
(div :class (str "p-3 rounded font-medium transition-colors "
|
||||
(if (deref danger) "bg-red-100 text-red-800" "bg-green-100 text-green-800"))
|
||||
:style (str "font-size:" (deref size) "px")
|
||||
"This element's class and style are reactive."))))
|
||||
|
||||
;; 11. Resource + suspense pattern — async data with loading/error states
|
||||
(defisland ~demo-resource ()
|
||||
(let ((data (resource (fn ()
|
||||
;; Simulate async fetch with a delayed promise
|
||||
(promise-delayed 1500 (dict "name" "Ada Lovelace"
|
||||
"role" "First Programmer"
|
||||
"year" 1843))))))
|
||||
(div :class "rounded border border-violet-200 bg-violet-50 p-4 my-4"
|
||||
(cond
|
||||
(get (deref data) "loading")
|
||||
(div :class "flex items-center gap-2 text-stone-500"
|
||||
(span :class "inline-block w-4 h-4 border-2 border-stone-300 border-t-violet-600 rounded-full animate-spin")
|
||||
(span :class "text-sm" "Loading..."))
|
||||
(get (deref data) "error")
|
||||
(div :class "p-3 bg-red-50 border border-red-200 rounded"
|
||||
(p :class "text-red-700 text-sm" "Error: " (get (deref data) "error")))
|
||||
:else
|
||||
(let ((d (get (deref data) "data")))
|
||||
(div :class "space-y-1"
|
||||
(p :class "font-bold text-stone-800" (get d "name"))
|
||||
(p :class "text-sm text-stone-600" (get d "role") " (" (get d "year") ")")))))))
|
||||
|
||||
;; 12. Transition pattern — deferred updates for expensive operations
|
||||
(defisland ~demo-transition ()
|
||||
(let ((query (signal ""))
|
||||
(all-items (list "Signals" "Effects" "Computed" "Batch" "Stores"
|
||||
"Islands" "Portals" "Error Boundaries" "Resources"
|
||||
"Input Binding" "Keyed Lists" "Event Bridge"
|
||||
"Reactive Text" "Reactive Attrs" "Reactive Fragments"
|
||||
"Disposal" "Hydration" "CSSX" "Macros" "Refs"))
|
||||
(filtered (signal (list)))
|
||||
(pending (signal false)))
|
||||
;; Set initial filtered list
|
||||
(reset! filtered all-items)
|
||||
;; Filter effect — defers via schedule-idle so typing stays snappy
|
||||
(effect (fn ()
|
||||
(let ((q (lower (deref query))))
|
||||
(if (= q "")
|
||||
(do (reset! pending false)
|
||||
(reset! filtered all-items))
|
||||
(do (reset! pending true)
|
||||
(schedule-idle (fn ()
|
||||
(batch (fn ()
|
||||
(reset! filtered
|
||||
(filter (fn (item) (contains? (lower item) q)) all-items))
|
||||
(reset! pending 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 query :placeholder "Filter features..."
|
||||
:class "px-3 py-1.5 rounded border border-stone-300 text-sm focus:outline-none focus:border-violet-400 w-48")
|
||||
(when (deref pending)
|
||||
(span :class "text-xs text-stone-400" "Filtering...")))
|
||||
(ul :class "space-y-1"
|
||||
(map (fn (item)
|
||||
(li :key item :class "text-sm text-stone-700 bg-white rounded px-3 py-1.5"
|
||||
item))
|
||||
(deref filtered))))))
|
||||
|
||||
;; 13. Shared stores — cross-island state via def-store / use-store
|
||||
(defisland ~demo-store-writer ()
|
||||
(let ((store (def-store "demo-theme" (fn ()
|
||||
(dict "color" (signal "violet")
|
||||
"dark" (signal false))))))
|
||||
(div :class "rounded border border-stone-200 bg-stone-50 p-4 my-2"
|
||||
(p :class "text-xs font-semibold text-stone-500 mb-2" "Island A — Store Writer")
|
||||
(div :class "flex items-center gap-3"
|
||||
(select :bind (get store "color")
|
||||
:class "px-2 py-1 rounded border border-stone-300 text-sm"
|
||||
(option :value "violet" "Violet")
|
||||
(option :value "blue" "Blue")
|
||||
(option :value "green" "Green")
|
||||
(option :value "red" "Red"))
|
||||
(label :class "flex items-center gap-1 text-sm text-stone-600"
|
||||
(input :type "checkbox" :bind (get store "dark")
|
||||
:class "rounded border-stone-300")
|
||||
"Dark mode")))))
|
||||
|
||||
(defisland ~demo-store-reader ()
|
||||
(let ((store (use-store "demo-theme")))
|
||||
(div :class "rounded border border-stone-200 bg-stone-50 p-4 my-2"
|
||||
(p :class "text-xs font-semibold text-stone-500 mb-2" "Island B — Store Reader")
|
||||
(div :class (str "p-3 rounded font-medium text-sm "
|
||||
(if (deref (get store "dark"))
|
||||
(str "bg-" (deref (get store "color")) "-900 text-" (deref (get store "color")) "-100")
|
||||
(str "bg-" (deref (get store "color")) "-100 text-" (deref (get store "color")) "-800")))
|
||||
"Styled by signals from Island A"))))
|
||||
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Demo page — shows what's been implemented
|
||||
@@ -378,19 +536,134 @@
|
||||
(~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 "8. How defisland Works" :id "how-defisland"
|
||||
(~doc-section :title "8. Error Boundaries" :id "demo-error-boundary"
|
||||
(p "When an island's rendering or effect throws, " (code "error-boundary") " catches the error and renders a fallback. The fallback receives the error and a retry function. Partial effects created before the error are disposed automatically.")
|
||||
(~demo-error-boundary)
|
||||
(~doc-code :code (highlight "(defisland ~demo-error-boundary ()\n (let ((throw? (signal false)))\n (error-boundary\n ;; Fallback: receives (err retry-fn)\n (fn (err retry-fn)\n (div :class \"p-3 bg-red-50 border border-red-200 rounded\"\n (p :class \"text-red-700\" (error-message err))\n (button :on-click (fn (e)\n (reset! throw? false) (invoke retry-fn))\n \"Retry\")))\n ;; Children: the happy path\n (do\n (when (deref throw?) (error \"Intentional explosion!\"))\n (p \"Everything is fine.\")))))" "lisp"))
|
||||
(p "React equivalent: " (code "componentDidCatch") " / " (code "ErrorBoundary") ". SX's version is simpler — one form, not a class. The " (code "error-boundary") " form is a render-dom special form in " (code "adapter-dom.sx") "."))
|
||||
|
||||
(~doc-section :title "9. Refs — Imperative DOM Access" :id "demo-refs"
|
||||
(p "The " (code ":ref") " attribute captures a DOM element handle into a dict. Use it for imperative operations: focusing, measuring, reading values.")
|
||||
(~demo-refs)
|
||||
(~doc-code :code (highlight "(defisland ~demo-refs ()\n (let ((my-ref (dict \"current\" nil))\n (msg (signal \"\")))\n (input :ref my-ref :type \"text\"\n :placeholder \"I can be focused programmatically\")\n (button :on-click (fn (e)\n (dom-focus (get my-ref \"current\")))\n \"Focus Input\")\n (button :on-click (fn (e)\n (let ((el (get my-ref \"current\")))\n (reset! msg (str \"value: \" (dom-get-prop el \"value\")))))\n \"Read Input\")\n (when (not (= (deref msg) \"\"))\n (p (deref msg)))))" "lisp"))
|
||||
(p "React equivalent: " (code "useRef") ". In SX, a ref is just " (code "(dict \"current\" nil)") " — no special API. The " (code ":ref") " attribute sets " (code "(dict-set! ref \"current\" el)") " when the element is created. Read it with " (code "(get ref \"current\")") "."))
|
||||
|
||||
(~doc-section :title "10. Dynamic Class and Style" :id "demo-dynamic-class"
|
||||
(p "React uses " (code "className") " and " (code "style") " props with state. SX does the same — " (code "(deref signal)") " inside a " (code ":class") " or " (code ":style") " attribute creates a reactive binding. The attribute updates when the signal changes.")
|
||||
(~demo-dynamic-class)
|
||||
(~doc-code :code (highlight "(defisland ~demo-dynamic-class ()\n (let ((danger (signal false))\n (size (signal 16)))\n (div\n (button :on-click (fn (e) (swap! danger not))\n (if (deref danger) \"Safe mode\" \"Danger mode\"))\n (button :on-click (fn (e) (swap! size (fn (s) (+ s 2))))\n \"Bigger\")\n ;; Reactive class — recomputed when danger changes\n (div :class (str \"p-3 rounded font-medium \"\n (if (deref danger)\n \"bg-red-100 text-red-800\"\n \"bg-green-100 text-green-800\"))\n ;; Reactive style — recomputed when size changes\n :style (str \"font-size:\" (deref size) \"px\")\n \"This element's class and style are reactive.\"))))" "lisp"))
|
||||
(p "React equivalent: " (code "className={danger ? 'red' : 'green'}") " and " (code "style={{fontSize: size}}") ". In SX the " (code "str") " + " (code "if") " + " (code "deref") " pattern handles it — no " (code "classnames") " library needed. For complex conditional classes, use a " (code "computed") " or a CSSX " (code "defcomp") " that returns a class string."))
|
||||
|
||||
(~doc-section :title "11. Resource + Suspense Pattern" :id "demo-resource"
|
||||
(p (code "resource") " wraps an async operation into a signal with " (code "loading") "/" (code "data") "/" (code "error") " states. Combined with " (code "cond") " + " (code "deref") ", this is the suspense pattern — no special form needed.")
|
||||
(~demo-resource)
|
||||
(~doc-code :code (highlight "(defisland ~demo-resource ()\n (let ((data (resource (fn ()\n ;; Any promise-returning function\n (promise-delayed 1500\n (dict \"name\" \"Ada Lovelace\"\n \"role\" \"First Programmer\"))))))\n ;; This IS the suspense pattern:\n (let ((state (deref data)))\n (cond\n (get state \"loading\")\n (div \"Loading...\")\n (get state \"error\")\n (div \"Error: \" (get state \"error\"))\n :else\n (div (get (get state \"data\") \"name\"))))))" "lisp"))
|
||||
(p "React equivalent: " (code "Suspense") " + " (code "use()") " or " (code "useSWR") ". SX doesn't need a special " (code "suspense") " form because " (code "resource") " returns a signal and " (code "cond") " + " (code "deref") " creates reactive conditional rendering. When the promise resolves, the signal updates and the " (code "cond") " branch switches automatically."))
|
||||
|
||||
(~doc-section :title "12. Transition Pattern" :id "demo-transition"
|
||||
(p "React's " (code "startTransition") " defers non-urgent updates so typing stays responsive. In SX: " (code "schedule-idle") " + " (code "batch") ". The filter runs during idle time, not blocking the input event.")
|
||||
(~demo-transition)
|
||||
(~doc-code :code (highlight "(defisland ~demo-transition ()\n (let ((query (signal \"\"))\n (all-items (list \"Signals\" \"Effects\" ...))\n (filtered (signal (list)))\n (pending (signal false)))\n (reset! filtered all-items)\n ;; Filter effect — deferred via schedule-idle\n (effect (fn ()\n (let ((q (lower (deref query))))\n (if (= q \"\")\n (do (reset! pending false)\n (reset! filtered all-items))\n (do (reset! pending true)\n (schedule-idle (fn ()\n (batch (fn ()\n (reset! filtered\n (filter (fn (item)\n (contains? (lower item) q))\n all-items))\n (reset! pending false))))))))))\n (div\n (input :bind query :placeholder \"Filter...\")\n (when (deref pending) (span \"Filtering...\"))\n (ul (map (fn (item) (li :key item item))\n (deref filtered))))))" "lisp"))
|
||||
(p "React equivalent: " (code "startTransition(() => setFiltered(...))") ". SX uses " (code "schedule-idle") " (" (code "requestIdleCallback") " under the hood) to defer the expensive " (code "filter") " operation, and " (code "batch") " to group the result into one update. Fine-grained signals already avoid the jank that makes transitions critical in React — this pattern is for truly expensive computations."))
|
||||
|
||||
(~doc-section :title "13. Shared Stores" :id "demo-stores"
|
||||
(p "React uses " (code "Context") " or state management libraries for cross-component state. SX uses " (code "def-store") " / " (code "use-store") " — named signal containers that persist across island creation/destruction.")
|
||||
(~demo-store-writer)
|
||||
(~demo-store-reader)
|
||||
(~doc-code :code (highlight ";; Island A — creates/writes the store\n(defisland ~store-writer ()\n (let ((store (def-store \"theme\" (fn ()\n (dict \"color\" (signal \"violet\")\n \"dark\" (signal false))))))\n (select :bind (get store \"color\")\n (option :value \"violet\" \"Violet\")\n (option :value \"blue\" \"Blue\"))\n (input :type \"checkbox\" :bind (get store \"dark\"))))\n\n;; Island B — reads the same store, different island\n(defisland ~store-reader ()\n (let ((store (use-store \"theme\")))\n (div :class (str \"bg-\" (deref (get store \"color\")) \"-100\")\n \"Styled by signals from Island A\")))" "lisp"))
|
||||
(p "React equivalent: " (code "createContext") " + " (code "useContext") " or Redux/Zustand. Stores are simpler — just named dicts of signals at page scope. " (code "def-store") " creates once, " (code "use-store") " retrieves. Stores survive island disposal but clear on full page navigation."))
|
||||
|
||||
(~doc-section :title "14. 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 "9. Test suite" :id "demo-tests"
|
||||
(~doc-section :title "15. 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, portals, error boundaries, and resource.")
|
||||
(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."))))
|
||||
(~doc-section :title "React Feature Coverage" :id "coverage"
|
||||
(p "Every React feature has an SX equivalent — most are simpler because signals are fine-grained.")
|
||||
(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" "React")
|
||||
(th :class "px-3 py-2 font-medium text-stone-600" "SX")
|
||||
(th :class "px-3 py-2 font-medium text-stone-600" "Demo")))
|
||||
(tbody
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 text-stone-700" "useState")
|
||||
(td :class "px-3 py-2 font-mono text-xs text-violet-700" "(signal value)")
|
||||
(td :class "px-3 py-2 text-xs text-stone-500" "#1"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 text-stone-700" "useMemo")
|
||||
(td :class "px-3 py-2 font-mono text-xs text-violet-700" "(computed (fn () ...))")
|
||||
(td :class "px-3 py-2 text-xs text-stone-500" "#1, #2"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 text-stone-700" "useEffect")
|
||||
(td :class "px-3 py-2 font-mono text-xs text-violet-700" "(effect (fn () ...))")
|
||||
(td :class "px-3 py-2 text-xs text-stone-500" "#3"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 text-stone-700" "useRef")
|
||||
(td :class "px-3 py-2 font-mono text-xs text-violet-700" "(dict \"current\" nil) + :ref")
|
||||
(td :class "px-3 py-2 text-xs text-stone-500" "#9"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 text-stone-700" "useCallback")
|
||||
(td :class "px-3 py-2 font-mono text-xs text-violet-700" "(fn (...) ...) — no dep arrays")
|
||||
(td :class "px-3 py-2 text-xs text-stone-500" "N/A"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 text-stone-700" "className / style")
|
||||
(td :class "px-3 py-2 font-mono text-xs text-violet-700" ":class (str ...) :style (str ...)")
|
||||
(td :class "px-3 py-2 text-xs text-stone-500" "#10"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 text-stone-700" "Controlled inputs")
|
||||
(td :class "px-3 py-2 font-mono text-xs text-violet-700" ":bind signal")
|
||||
(td :class "px-3 py-2 text-xs text-stone-500" "#6"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 text-stone-700" "key prop")
|
||||
(td :class "px-3 py-2 font-mono text-xs text-violet-700" ":key value")
|
||||
(td :class "px-3 py-2 text-xs text-stone-500" "#5"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 text-stone-700" "createPortal")
|
||||
(td :class "px-3 py-2 font-mono text-xs text-violet-700" "(portal \"#target\" ...)")
|
||||
(td :class "px-3 py-2 text-xs text-stone-500" "#7"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 text-stone-700" "ErrorBoundary")
|
||||
(td :class "px-3 py-2 font-mono text-xs text-violet-700" "(error-boundary fallback ...)")
|
||||
(td :class "px-3 py-2 text-xs text-stone-500" "#8"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 text-stone-700" "Suspense + use()")
|
||||
(td :class "px-3 py-2 font-mono text-xs text-violet-700" "(resource fn) + cond/deref")
|
||||
(td :class "px-3 py-2 text-xs text-stone-500" "#11"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 text-stone-700" "startTransition")
|
||||
(td :class "px-3 py-2 font-mono text-xs text-violet-700" "schedule-idle + batch")
|
||||
(td :class "px-3 py-2 text-xs text-stone-500" "#12"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 text-stone-700" "Context / Redux")
|
||||
(td :class "px-3 py-2 font-mono text-xs text-violet-700" "def-store / use-store")
|
||||
(td :class "px-3 py-2 text-xs text-stone-500" "#13"))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 text-stone-700" "Virtual DOM / diffing")
|
||||
(td :class "px-3 py-2 text-xs text-stone-500" "N/A — fine-grained signals update exact DOM nodes")
|
||||
(td :class "px-3 py-2 text-xs text-stone-500" ""))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 text-stone-700" "JSX / build step")
|
||||
(td :class "px-3 py-2 text-xs text-stone-500" "N/A — s-expressions are the syntax")
|
||||
(td :class "px-3 py-2 text-xs text-stone-500" ""))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 text-stone-700" "Server Components")
|
||||
(td :class "px-3 py-2 text-xs text-stone-500" "N/A — aser mode already expands server-side")
|
||||
(td :class "px-3 py-2 text-xs text-stone-500" ""))
|
||||
(tr :class "border-b border-stone-100"
|
||||
(td :class "px-3 py-2 text-stone-700" "Concurrent rendering")
|
||||
(td :class "px-3 py-2 text-xs text-stone-500" "N/A — fine-grained updates are inherently incremental")
|
||||
(td :class "px-3 py-2 text-xs text-stone-500" ""))
|
||||
(tr
|
||||
(td :class "px-3 py-2 text-stone-700" "Hooks rules")
|
||||
(td :class "px-3 py-2 text-xs text-stone-500" "N/A — signals are values, no ordering rules")
|
||||
(td :class "px-3 py-2 text-xs text-stone-500" ""))))))))
|
||||
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
@@ -282,6 +282,7 @@
|
||||
"sx-and-ai" (~essay-sx-and-ai)
|
||||
"no-alternative" (~essay-no-alternative)
|
||||
"zero-tooling" (~essay-zero-tooling)
|
||||
"react-is-hypermedia" (~essay-react-is-hypermedia)
|
||||
:else (~essays-index-content)))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user