Adapter fixes, orchestration updates, example content + SPA tests

From other session: adapter-html/sx/dom fixes, orchestration
improvements, examples-content refactoring, SPA navigation test
updates, WASM copies synced.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-01 13:35:49 +00:00
parent cd9ebc0cd8
commit 46f77c3b1e
15 changed files with 442 additions and 231 deletions

View File

@@ -1302,8 +1302,8 @@
(fn (fn
((args :as list) (env :as dict) (ns :as string)) ((args :as list) (env :as dict) (ns :as string))
(let (let
((fallback-expr (first args)) ((fallback-expr (if (> (len args) 1) (first args) nil))
(body-exprs (rest args)) (body-exprs (if (> (len args) 1) (rest args) args))
(container (dom-create-element "div" nil)) (container (dom-create-element "div" nil))
(retry-version (signal 0))) (retry-version (signal 0)))
(dom-set-attr container "data-sx-boundary" "true") (dom-set-attr container "data-sx-boundary" "true")
@@ -1333,6 +1333,6 @@
(retry-fn (retry-fn
(fn () (swap! retry-version (fn (n) (+ n 1)))))) (fn () (swap! retry-version (fn (n) (+ n 1))))))
(let (let
((fallback-dom (if (lambda? fallback-fn) (render-lambda-dom fallback-fn (list err retry-fn) env ns) (render-to-dom (apply fallback-fn (list err retry-fn)) env ns)))) ((fallback-dom (if (nil? fallback-fn) (let ((el (dom-create-element "div" nil))) (dom-set-attr el "class" "sx-render-error") (dom-set-attr el "style" "color:red;font-size:0.875rem;padding:0.5rem;border:1px solid red;border-radius:0.25rem;margin:0.5rem 0;") (dom-set-text-content el (str "Render error: " err)) el) (if (lambda? fallback-fn) (render-lambda-dom fallback-fn (list err retry-fn) env ns) (render-to-dom (apply fallback-fn (list err retry-fn)) env ns)))))
(dom-append container fallback-dom))))))) (dom-append container fallback-dom)))))))
container))) container)))

File diff suppressed because one or more lines are too long

View File

@@ -110,10 +110,47 @@
(render-html-lake args env) (render-html-lake args env)
(= name "marsh") (= name "marsh")
(render-html-marsh args env) (render-html-marsh args env)
(or (= name "error-boundary")
(= name "portal") (let
(= name "error-boundary") ((has-fallback (> (len args) 1)))
(= name "promise-delayed")) (let
((body-exprs (if has-fallback (rest args) args))
(fallback-expr (if has-fallback (first args) nil)))
(str
"<div data-sx-boundary=\"true\">"
(try-catch
(fn
()
(join
""
(map (fn (x) (render-to-html x env)) body-exprs)))
(fn
(err)
(let
((safe-err (replace (replace (str err) "<" "&lt;") ">" "&gt;")))
(if
(and fallback-expr (not (nil? fallback-expr)))
(try-catch
(fn
()
(render-to-html
(list
(trampoline (eval-expr fallback-expr env))
err
nil)
env))
(fn
(e2)
(str
"<div class=\"sx-render-error\" style=\"color:red;font-size:0.875rem;padding:0.5rem;border:1px solid red;border-radius:0.25rem;margin:0.5rem 0;\">Render error: "
safe-err
"</div>")))
(str
"<div class=\"sx-render-error\" style=\"color:red;font-size:0.875rem;padding:0.5rem;border:1px solid red;border-radius:0.25rem;margin:0.5rem 0;\">Render error: "
safe-err
"</div>")))))
"</div>")))
(or (= name "portal") (= name "promise-delayed"))
(join "" (map (fn (x) (render-to-html x env)) args)) (join "" (map (fn (x) (render-to-html x env)) args))
(contains? HTML_TAGS name) (contains? HTML_TAGS name)
(render-html-element name args env) (render-html-element name args env)

File diff suppressed because one or more lines are too long

View File

@@ -66,7 +66,24 @@
(= name "marsh") (= name "marsh")
(aser-call name args env) (aser-call name args env)
(= name "error-boundary") (= name "error-boundary")
(aser-call name args env) (let
((has-fallback (> (len args) 1)))
(let
((body-exprs (if has-fallback (rest args) args))
(err-str nil))
(let
((rendered (try-catch (fn () (join "" (map (fn (x) (let ((v (aser x env))) (cond (= (type-of v) "sx-expr") (sx-expr-source v) (nil? v) "" :else (serialize v)))) body-exprs))) (fn (err) (set! err-str (str err)) nil))))
(if
rendered
(make-sx-expr (str "(error-boundary " rendered ")"))
(make-sx-expr
(str
"(div :data-sx-boundary \"true\" "
"(div :class \"sx-render-error\" "
":style \"color:red;font-size:0.875rem;padding:0.5rem;border:1px solid red;border-radius:0.25rem;margin:0.5rem 0;\" "
"\"Render error: "
(replace (replace err-str "\"" "'") "\\" "\\\\")
"\"))"))))))
(contains? HTML_TAGS name) (contains? HTML_TAGS name)
(aser-call name args env) (aser-call name args env)
(or (special-form? name) (ho-form? name)) (or (special-form? name) (ho-form? name))

File diff suppressed because one or more lines are too long

View File

@@ -288,24 +288,21 @@
s) s)
(post-swap t))) (post-swap t)))
(let (let
((select-sel (dom-get-attr el "sx-select")) ((select-sel (dom-get-attr el "sx-select")))
(content (let
(if ((content (if select-sel (select-from-container container select-sel) (children-to-fragment container))))
select-sel (dispose-islands-in target)
(select-from-container container select-sel) (with-transition
(children-to-fragment container)))) use-transition
(dispose-islands-in target) (fn
(with-transition ()
use-transition (let
(fn ((swap-result (swap-dom-nodes target content swap-style)))
() (post-swap
(let (if
((swap-result (swap-dom-nodes target content swap-style))) (= swap-style "outerHTML")
(post-swap (dom-parent (or swap-result target))
(if (or swap-result target)))))))))))))))
(= swap-style "outerHTML")
(dom-parent (or swap-result target))
(or swap-result target))))))))))))))
(define (define
handle-html-response handle-html-response
@@ -973,17 +970,35 @@
:effects (mutation io) :effects (mutation io)
(fn (fn
(target rendered (pathname :as string)) (target rendered (pathname :as string))
(do (let
(dispose-islands-in target) ((container (dom-create-element "div" nil)))
(dom-set-text-content target "") (dom-append container rendered)
(dom-append target rendered) (process-oob-swaps
(hoist-head-elements-full target) container
(process-elements target) (fn
(sx-hydrate-elements target) (t oob (s :as string))
(sx-hydrate-islands target) (dispose-islands-in t)
(run-post-render-hooks) (swap-dom-nodes
(dom-dispatch target "sx:clientRoute" (dict "pathname" pathname)) t
(log-info (str "sx:route client " pathname))))) (if (= s "innerHTML") (children-to-fragment oob) oob)
s)
(post-swap t)))
(let
((target-id (dom-get-attr target "id")))
(let
((inner (if target-id (dom-query container (str "#" target-id)) nil)))
(let
((content (if inner (children-to-fragment inner) (children-to-fragment container))))
(dispose-islands-in target)
(dom-set-text-content target "")
(dom-append target content)
(hoist-head-elements-full target)
(process-elements target)
(sx-hydrate-elements target)
(sx-hydrate-islands target)
(run-post-render-hooks)
(dom-dispatch target "sx:clientRoute" (dict "pathname" pathname))
(log-info (str "sx:route client " pathname))))))))
(define (define
resolve-route-target resolve-route-target

File diff suppressed because one or more lines are too long

View File

@@ -1,316 +1,372 @@
;; Example page defcomps — one per example. (defcomp
;; Each calls ~examples/page-content with static string data. ~examples-content/example-click-to-load
;; Replaces all _example_*_sx() builders in essays.py. ()
(defcomp ~examples-content/example-click-to-load ()
(~examples/page-content (~examples/page-content
:title "Click to Load" :title "Click to Load"
:description "The simplest sx interaction: click a button, fetch content from the server, swap it in." :description "The simplest sx interaction: click a button, fetch content from the server, swap it in."
:demo-description "Click the button to load server-rendered content." :demo-description "Click the button to load server-rendered content."
:demo (~examples/click-to-load-demo) :demo (~examples/click-to-load-demo)
:sx-code "(button\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.click))))\"\n :sx-target \"#click-result\"\n :sx-swap \"innerHTML\"\n \"Load content\")" :sx-code "(button\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.click))))\"\n :sx-target \"#click-result\"\n :sx-swap \"innerHTML\"\n \"Load content\")"
:handler-code (handler-source "ex-click") :handler-names (list "ex-click")
:comp-placeholder-id "click-comp" :comp-placeholder-id "click-comp"
:wire-placeholder-id "click-wire" :wire-placeholder-id "click-wire"
:wire-note "The server responds with content-type text/sx. New CSS rules are prepended as a style tag. Clear the component cache to see component definitions included in the wire response.")) :wire-note "The server responds with content-type text/sx. New CSS rules are prepended as a style tag. Clear the component cache to see component definitions included in the wire response."))
(defcomp ~examples-content/example-form-submission () (defcomp
~examples-content/example-form-submission
()
(~examples/page-content (~examples/page-content
:title "Form Submission" :title "Form Submission"
:description "Forms with sx-post submit via AJAX and swap the response into a target." :description "Forms with sx-post submit via AJAX and swap the response into a target."
:demo-description "Enter a name and submit." :demo-description "Enter a name and submit."
:demo (~examples/form-demo) :demo (~examples/form-demo)
:sx-code "(form\n :sx-post \"/sx/(geography.(hypermedia.(example.(api.form))))\"\n :sx-target \"#form-result\"\n :sx-swap \"innerHTML\"\n (input :type \"text\" :name \"name\")\n (button :type \"submit\" \"Submit\"))" :sx-code "(form\n :sx-post \"/sx/(geography.(hypermedia.(example.(api.form))))\"\n :sx-target \"#form-result\"\n :sx-swap \"innerHTML\"\n (input :type \"text\" :name \"name\")\n (button :type \"submit\" \"Submit\"))"
:handler-code (handler-source "ex-form") :handler-names (list "ex-form")
:comp-placeholder-id "form-comp" :comp-placeholder-id "form-comp"
:wire-placeholder-id "form-wire")) :wire-placeholder-id "form-wire"))
(defcomp ~examples-content/example-polling () (defcomp
~examples-content/example-polling
()
(~examples/page-content (~examples/page-content
:title "Polling" :title "Polling"
:description "Use sx-trigger with \"every\" to poll the server at regular intervals." :description "Use sx-trigger with \"every\" to poll the server at regular intervals."
:demo-description "This div polls the server every 2 seconds." :demo-description "This div polls the server every 2 seconds."
:demo (~examples/polling-demo) :demo (~examples/polling-demo)
:sx-code "(div\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.poll))))\"\n :sx-trigger \"load, every 2s\"\n :sx-swap \"innerHTML\"\n \"Loading...\")" :sx-code "(div\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.poll))))\"\n :sx-trigger \"load, every 2s\"\n :sx-swap \"innerHTML\"\n \"Loading...\")"
:handler-code (handler-source "ex-poll") :handler-names (list "ex-poll")
:comp-placeholder-id "poll-comp" :comp-placeholder-id "poll-comp"
:wire-placeholder-id "poll-wire" :wire-placeholder-id "poll-wire"
:wire-note "Updates every 2 seconds — watch the time and count change.")) :wire-note "Updates every 2 seconds — watch the time and count change."))
(defcomp ~examples-content/example-delete-row () (defcomp
~examples-content/example-delete-row
()
(~examples/page-content (~examples/page-content
:title "Delete Row" :title "Delete Row"
:description "sx-delete with sx-swap \"outerHTML\" and an empty response removes the row from the DOM." :description "sx-delete with sx-swap \"outerHTML\" and an empty response removes the row from the DOM."
:demo-description "Click delete to remove a row. Uses sx-confirm for confirmation." :demo-description "Click delete to remove a row. Uses sx-confirm for confirmation."
:demo (~examples/delete-demo :items (list :demo (~examples/delete-demo
(list "1" "Implement dark mode") :items (list
(list "2" "Fix login bug") (list "1" "Implement dark mode")
(list "3" "Write documentation") (list "2" "Fix login bug")
(list "4" "Deploy to production") (list "3" "Write documentation")
(list "5" "Add unit tests"))) (list "4" "Deploy to production")
(list "5" "Add unit tests")))
:sx-code "(button\n :sx-delete \"/sx/(geography.(hypermedia.(example.(api.(delete.1)))))\"\n :sx-target \"#row-1\"\n :sx-swap \"outerHTML\"\n :sx-confirm \"Delete this item?\"\n \"delete\")" :sx-code "(button\n :sx-delete \"/sx/(geography.(hypermedia.(example.(api.(delete.1)))))\"\n :sx-target \"#row-1\"\n :sx-swap \"outerHTML\"\n :sx-confirm \"Delete this item?\"\n \"delete\")"
:handler-code (handler-source "ex-delete") :handler-names (list "ex-delete")
:comp-placeholder-id "delete-comp" :comp-placeholder-id "delete-comp"
:wire-placeholder-id "delete-wire" :wire-placeholder-id "delete-wire"
:wire-note "Empty body — outerHTML swap replaces the target element with nothing.")) :wire-note "Empty body — outerHTML swap replaces the target element with nothing."))
(defcomp ~examples-content/example-inline-edit () (defcomp
~examples-content/example-inline-edit
()
(~examples/page-content (~examples/page-content
:title "Inline Edit" :title "Inline Edit"
:description "Click edit to swap a display view for an edit form. Save swaps back." :description "Click edit to swap a display view for an edit form. Save swaps back."
:demo-description "Click edit, modify the text, save or cancel." :demo-description "Click edit, modify the text, save or cancel."
:demo (~examples/inline-edit-demo) :demo (~examples/inline-edit-demo)
:sx-code ";; View mode — shows text + edit button\n(~examples/inline-view :value \"some text\")\n\n;; Edit mode — returned by server on click\n(~examples/inline-edit-form :value \"some text\")" :sx-code ";; View mode — shows text + edit button\n(~examples/inline-view :value \"some text\")\n\n;; Edit mode — returned by server on click\n(~examples/inline-edit-form :value \"some text\")"
:handler-code (str (handler-source "ex-edit-form") "\n\n" (handler-source "ex-edit-save")) :handler-names (list "ex-edit-form" "ex-edit-save")
:comp-placeholder-id "edit-comp" :comp-placeholder-id "edit-comp"
:comp-heading "Components" :comp-heading "Components"
:handler-heading "Server handlers" :handler-heading "Server handlers"
:wire-placeholder-id "edit-wire")) :wire-placeholder-id "edit-wire"))
(defcomp ~examples-content/example-oob-swaps () (defcomp
~examples-content/example-oob-swaps
()
(~examples/page-content (~examples/page-content
:title "Out-of-Band Swaps" :title "Out-of-Band Swaps"
:description "sx-swap-oob lets a single response update multiple elements anywhere in the DOM." :description "sx-swap-oob lets a single response update multiple elements anywhere in the DOM."
:demo-description "One request updates both Box A (via sx-target) and Box B (via sx-swap-oob)." :demo-description "One request updates both Box A (via sx-target) and Box B (via sx-swap-oob)."
:demo (~examples/oob-demo) :demo (~examples/oob-demo)
:sx-code ";; Button targets Box A\n(button\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.oob))))\"\n :sx-target \"#oob-box-a\"\n :sx-swap \"innerHTML\"\n \"Update both boxes\")" :sx-code ";; Button targets Box A\n(button\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.oob))))\"\n :sx-target \"#oob-box-a\"\n :sx-swap \"innerHTML\"\n \"Update both boxes\")"
:handler-code (handler-source "ex-oob") :handler-names (list "ex-oob")
:wire-placeholder-id "oob-wire" :wire-placeholder-id "oob-wire"
:wire-note "The fragment contains both the main content and an OOB element. sx.js splits them: main content goes to sx-target, OOB elements find their targets by ID.")) :wire-note "The fragment contains both the main content and an OOB element. sx.js splits them: main content goes to sx-target, OOB elements find their targets by ID."))
(defcomp ~examples-content/example-lazy-loading () (defcomp
~examples-content/example-lazy-loading
()
(~examples/page-content (~examples/page-content
:title "Lazy Loading" :title "Lazy Loading"
:description "Use sx-trigger=\"load\" to fetch content as soon as the element enters the DOM. Great for deferring expensive content below the fold." :description "Use sx-trigger=\"load\" to fetch content as soon as the element enters the DOM. Great for deferring expensive content below the fold."
:demo-description "Content loads automatically when the page renders." :demo-description "Content loads automatically when the page renders."
:demo (~examples/lazy-loading-demo) :demo (~examples/lazy-loading-demo)
:sx-code "(div\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.lazy))))\"\n :sx-trigger \"load\"\n :sx-swap \"innerHTML\"\n (div :class \"animate-pulse\" \"Loading...\"))" :sx-code "(div\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.lazy))))\"\n :sx-trigger \"load\"\n :sx-swap \"innerHTML\"\n (div :class \"animate-pulse\" \"Loading...\"))"
:handler-code (handler-source "ex-lazy") :handler-names (list "ex-lazy")
:comp-placeholder-id "lazy-comp" :comp-placeholder-id "lazy-comp"
:wire-placeholder-id "lazy-wire")) :wire-placeholder-id "lazy-wire"))
(defcomp ~examples-content/example-infinite-scroll () (defcomp
~examples-content/example-infinite-scroll
()
(~examples/page-content (~examples/page-content
:title "Infinite Scroll" :title "Infinite Scroll"
:description "A sentinel element at the bottom uses sx-trigger=\"intersect once\" to load the next page when scrolled into view. Each response appends items and a new sentinel." :description "A sentinel element at the bottom uses sx-trigger=\"intersect once\" to load the next page when scrolled into view. Each response appends items and a new sentinel."
:demo-description "Scroll down in the container to load more items (5 pages total)." :demo-description "Scroll down in the container to load more items (5 pages total)."
:demo (~examples/infinite-scroll-demo) :demo (~examples/infinite-scroll-demo)
:sx-code "(div :id \"scroll-sentinel\"\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.scroll))))?page=2\"\n :sx-trigger \"intersect once\"\n :sx-target \"#scroll-items\"\n :sx-swap \"beforeend\"\n \"Loading more...\")" :sx-code "(div :id \"scroll-sentinel\"\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.scroll))))?page=2\"\n :sx-trigger \"intersect once\"\n :sx-target \"#scroll-items\"\n :sx-swap \"beforeend\"\n \"Loading more...\")"
:handler-code (handler-source "ex-scroll") :handler-names (list "ex-scroll")
:comp-placeholder-id "scroll-comp" :comp-placeholder-id "scroll-comp"
:wire-placeholder-id "scroll-wire")) :wire-placeholder-id "scroll-wire"))
(defcomp ~examples-content/example-progress-bar () (defcomp
~examples-content/example-progress-bar
()
(~examples/page-content (~examples/page-content
:title "Progress Bar" :title "Progress Bar"
:description "Start a server-side job, then poll for progress using sx-trigger=\"load delay:500ms\" on each response. The bar fills up and stops when complete." :description "Start a server-side job, then poll for progress using sx-trigger=\"load delay:500ms\" on each response. The bar fills up and stops when complete."
:demo-description "Click start to begin a simulated job." :demo-description "Click start to begin a simulated job."
:demo (~examples/progress-bar-demo) :demo (~examples/progress-bar-demo)
:sx-code ";; Start the job\n(button\n :sx-post \"/sx/(geography.(hypermedia.(example.(api.progress-start))))\"\n :sx-target \"#progress-target\"\n :sx-swap \"innerHTML\")\n\n;; Each response re-polls via sx-trigger=\"load\"\n(div :sx-get \"/sx/(geography.(hypermedia.(example.(api.progress-status))))?job=ID\"\n :sx-trigger \"load delay:500ms\"\n :sx-target \"#progress-target\"\n :sx-swap \"innerHTML\")" :sx-code ";; Start the job\n(button\n :sx-post \"/sx/(geography.(hypermedia.(example.(api.progress-start))))\"\n :sx-target \"#progress-target\"\n :sx-swap \"innerHTML\")\n\n;; Each response re-polls via sx-trigger=\"load\"\n(div :sx-get \"/sx/(geography.(hypermedia.(example.(api.progress-status))))?job=ID\"\n :sx-trigger \"load delay:500ms\"\n :sx-target \"#progress-target\"\n :sx-swap \"innerHTML\")"
:handler-code (str (handler-source "ex-progress-start") "\n\n" (handler-source "ex-progress-status")) :handler-names (list "ex-progress-start" "ex-progress-status")
:comp-placeholder-id "progress-comp" :comp-placeholder-id "progress-comp"
:wire-placeholder-id "progress-wire")) :wire-placeholder-id "progress-wire"))
(defcomp ~examples-content/example-active-search () (defcomp
~examples-content/example-active-search
()
(~examples/page-content (~examples/page-content
:title "Active Search" :title "Active Search"
:description "An input with sx-trigger=\"keyup delay:300ms changed\" debounces keystrokes and only fires when the value changes. The server filters a list of programming languages." :description "An input with sx-trigger=\"keyup delay:300ms changed\" debounces keystrokes and only fires when the value changes. The server filters a list of programming languages."
:demo-description "Type to search through 20 programming languages." :demo-description "Type to search through 20 programming languages."
:demo (~examples/active-search-demo) :demo (~examples/active-search-demo)
:sx-code "(input :type \"text\" :name \"q\"\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.search))))\"\n :sx-trigger \"keyup delay:300ms changed\"\n :sx-target \"#search-results\"\n :sx-swap \"innerHTML\"\n :placeholder \"Search...\")" :sx-code "(input :type \"text\" :name \"q\"\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.search))))\"\n :sx-trigger \"keyup delay:300ms changed\"\n :sx-target \"#search-results\"\n :sx-swap \"innerHTML\"\n :placeholder \"Search...\")"
:handler-code (handler-source "ex-search") :handler-names (list "ex-search")
:comp-placeholder-id "search-comp" :comp-placeholder-id "search-comp"
:wire-placeholder-id "search-wire")) :wire-placeholder-id "search-wire"))
(defcomp ~examples-content/example-inline-validation () (defcomp
~examples-content/example-inline-validation
()
(~examples/page-content (~examples/page-content
:title "Inline Validation" :title "Inline Validation"
:description "Validate an email field on blur. The server checks format and whether it is taken, returning green or red feedback inline." :description "Validate an email field on blur. The server checks format and whether it is taken, returning green or red feedback inline."
:demo-description "Enter an email and click away (blur) to validate." :demo-description "Enter an email and click away (blur) to validate."
:demo (~examples/inline-validation-demo) :demo (~examples/inline-validation-demo)
:sx-code "(input :type \"text\" :name \"email\"\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.validate))))\"\n :sx-trigger \"blur\"\n :sx-target \"#email-feedback\"\n :sx-swap \"innerHTML\"\n :placeholder \"user@example.com\")" :sx-code "(input :type \"text\" :name \"email\"\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.validate))))\"\n :sx-trigger \"blur\"\n :sx-target \"#email-feedback\"\n :sx-swap \"innerHTML\"\n :placeholder \"user@example.com\")"
:handler-code (handler-source "ex-validate") :handler-names (list "ex-validate")
:comp-placeholder-id "validate-comp" :comp-placeholder-id "validate-comp"
:wire-placeholder-id "validate-wire")) :wire-placeholder-id "validate-wire"))
(defcomp ~examples-content/example-value-select () (defcomp
~examples-content/example-value-select
()
(~examples/page-content (~examples/page-content
:title "Value Select" :title "Value Select"
:description "Two linked selects: pick a category and the second select updates with matching items via sx-get." :description "Two linked selects: pick a category and the second select updates with matching items via sx-get."
:demo-description "Select a category to populate the item dropdown." :demo-description "Select a category to populate the item dropdown."
:demo (~examples/value-select-demo) :demo (~examples/value-select-demo)
:sx-code "(select :name \"category\"\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.values))))\"\n :sx-trigger \"change\"\n :sx-target \"#value-items\"\n :sx-swap \"innerHTML\"\n (option \"Languages\")\n (option \"Frameworks\")\n (option \"Databases\"))" :sx-code "(select :name \"category\"\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.values))))\"\n :sx-trigger \"change\"\n :sx-target \"#value-items\"\n :sx-swap \"innerHTML\"\n (option \"Languages\")\n (option \"Frameworks\")\n (option \"Databases\"))"
:handler-code (handler-source "ex-values") :handler-names (list "ex-values")
:comp-placeholder-id "values-comp" :comp-placeholder-id "values-comp"
:wire-placeholder-id "values-wire")) :wire-placeholder-id "values-wire"))
(defcomp ~examples-content/example-reset-on-submit () (defcomp
~examples-content/example-reset-on-submit
()
(~examples/page-content (~examples/page-content
:title "Reset on Submit" :title "Reset on Submit"
:description "Use sx-on:afterSwap=\"this.reset()\" to clear form inputs after a successful submission." :description "Use sx-on:afterSwap=\"this.reset()\" to clear form inputs after a successful submission."
:demo-description "Submit a message — the input resets after each send." :demo-description "Submit a message — the input resets after each send."
:demo (~examples/reset-on-submit-demo) :demo (~examples/reset-on-submit-demo)
:sx-code "(form :id \"reset-form\"\n :sx-post \"/sx/(geography.(hypermedia.(example.(api.reset-submit))))\"\n :sx-target \"#reset-result\"\n :sx-swap \"innerHTML\"\n :sx-on:afterSwap \"this.reset()\"\n (input :type \"text\" :name \"message\")\n (button :type \"submit\" \"Send\"))" :sx-code "(form :id \"reset-form\"\n :sx-post \"/sx/(geography.(hypermedia.(example.(api.reset-submit))))\"\n :sx-target \"#reset-result\"\n :sx-swap \"innerHTML\"\n :sx-on:afterSwap \"this.reset()\"\n (input :type \"text\" :name \"message\")\n (button :type \"submit\" \"Send\"))"
:handler-code (handler-source "ex-reset-submit") :handler-names (list "ex-reset-submit")
:comp-placeholder-id "reset-comp" :comp-placeholder-id "reset-comp"
:wire-placeholder-id "reset-wire")) :wire-placeholder-id "reset-wire"))
(defcomp ~examples-content/example-edit-row () (defcomp
~examples-content/example-edit-row
()
(~examples/page-content (~examples/page-content
:title "Edit Row" :title "Edit Row"
:description "Click edit to replace a table row with input fields. Save or cancel swaps back the display row. Uses sx-include to gather form values from the row." :description "Click edit to replace a table row with input fields. Save or cancel swaps back the display row. Uses sx-include to gather form values from the row."
:demo-description "Click edit on any row to modify it inline." :demo-description "Click edit on any row to modify it inline."
:demo (~examples/edit-row-demo :rows (list :demo (~examples/edit-row-demo
(list "1" "Widget A" "19.99" "142") :rows (list
(list "2" "Widget B" "24.50" "89") (list "1" "Widget A" "19.99" "142")
(list "3" "Widget C" "12.00" "305") (list "2" "Widget B" "24.50" "89")
(list "4" "Widget D" "45.00" "67"))) (list "3" "Widget C" "12.00" "305")
(list "4" "Widget D" "45.00" "67")))
:sx-code "(button\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.(editrow.1)))))\"\n :sx-target \"#erow-1\"\n :sx-swap \"outerHTML\"\n \"edit\")\n\n;; Save sends form data via POST\n(button\n :sx-post \"/sx/(geography.(hypermedia.(example.(api.(editrow.1)))))\"\n :sx-target \"#erow-1\"\n :sx-swap \"outerHTML\"\n :sx-include \"#erow-1\"\n \"save\")" :sx-code "(button\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.(editrow.1)))))\"\n :sx-target \"#erow-1\"\n :sx-swap \"outerHTML\"\n \"edit\")\n\n;; Save sends form data via POST\n(button\n :sx-post \"/sx/(geography.(hypermedia.(example.(api.(editrow.1)))))\"\n :sx-target \"#erow-1\"\n :sx-swap \"outerHTML\"\n :sx-include \"#erow-1\"\n \"save\")"
:handler-code (str (handler-source "ex-editrow-form") "\n\n" (handler-source "ex-editrow-save")) :handler-names (list "ex-editrow-form" "ex-editrow-save")
:comp-placeholder-id "editrow-comp" :comp-placeholder-id "editrow-comp"
:wire-placeholder-id "editrow-wire")) :wire-placeholder-id "editrow-wire"))
(defcomp ~examples-content/example-bulk-update () (defcomp
~examples-content/example-bulk-update
()
(~examples/page-content (~examples/page-content
:title "Bulk Update" :title "Bulk Update"
:description "Select rows with checkboxes and use Activate/Deactivate buttons. sx-include gathers checkbox values from the form." :description "Select rows with checkboxes and use Activate/Deactivate buttons. sx-include gathers checkbox values from the form."
:demo-description "Check some rows, then click Activate or Deactivate." :demo-description "Check some rows, then click Activate or Deactivate."
:demo (~examples/bulk-update-demo :users (list :demo (~examples/bulk-update-demo
(list "1" "Alice Chen" "alice@example.com" "active") :users (list
(list "2" "Bob Rivera" "bob@example.com" "inactive") (list "1" "Alice Chen" "alice@example.com" "active")
(list "3" "Carol Zhang" "carol@example.com" "active") (list "2" "Bob Rivera" "bob@example.com" "inactive")
(list "4" "Dan Okafor" "dan@example.com" "inactive") (list "3" "Carol Zhang" "carol@example.com" "active")
(list "5" "Eve Larsson" "eve@example.com" "active"))) (list "4" "Dan Okafor" "dan@example.com" "inactive")
(list "5" "Eve Larsson" "eve@example.com" "active")))
:sx-code "(button\n :sx-post \"/sx/(geography.(hypermedia.(example.(api.bulk))))?action=activate\"\n :sx-target \"#bulk-table\"\n :sx-swap \"innerHTML\"\n :sx-include \"#bulk-form\"\n \"Activate\")" :sx-code "(button\n :sx-post \"/sx/(geography.(hypermedia.(example.(api.bulk))))?action=activate\"\n :sx-target \"#bulk-table\"\n :sx-swap \"innerHTML\"\n :sx-include \"#bulk-form\"\n \"Activate\")"
:handler-code (handler-source "ex-bulk") :handler-names (list "ex-bulk")
:comp-placeholder-id "bulk-comp" :comp-placeholder-id "bulk-comp"
:wire-placeholder-id "bulk-wire")) :wire-placeholder-id "bulk-wire"))
(defcomp ~examples-content/example-swap-positions () (defcomp
~examples-content/example-swap-positions
()
(~examples/page-content (~examples/page-content
:title "Swap Positions" :title "Swap Positions"
:description "Demonstrates different swap modes: beforeend appends, afterbegin prepends, and none skips the main swap while still processing OOB updates." :description "Demonstrates different swap modes: beforeend appends, afterbegin prepends, and none skips the main swap while still processing OOB updates."
:demo-description "Try each button to see different swap behaviours." :demo-description "Try each button to see different swap behaviours."
:demo (~examples/swap-positions-demo) :demo (~examples/swap-positions-demo)
:sx-code ";; Append to end\n(button :sx-post \"/sx/(geography.(hypermedia.(example.(api.swap-log))))?mode=beforeend\"\n :sx-target \"#swap-log\" :sx-swap \"beforeend\"\n \"Add to End\")\n\n;; Prepend to start\n(button :sx-post \"/sx/(geography.(hypermedia.(example.(api.swap-log))))?mode=afterbegin\"\n :sx-target \"#swap-log\" :sx-swap \"afterbegin\"\n \"Add to Start\")\n\n;; No swap — OOB counter update only\n(button :sx-post \"/sx/(geography.(hypermedia.(example.(api.swap-log))))?mode=none\"\n :sx-target \"#swap-log\" :sx-swap \"none\"\n \"Silent Ping\")" :sx-code ";; Append to end\n(button :sx-post \"/sx/(geography.(hypermedia.(example.(api.swap-log))))?mode=beforeend\"\n :sx-target \"#swap-log\" :sx-swap \"beforeend\"\n \"Add to End\")\n\n;; Prepend to start\n(button :sx-post \"/sx/(geography.(hypermedia.(example.(api.swap-log))))?mode=afterbegin\"\n :sx-target \"#swap-log\" :sx-swap \"afterbegin\"\n \"Add to Start\")\n\n;; No swap — OOB counter update only\n(button :sx-post \"/sx/(geography.(hypermedia.(example.(api.swap-log))))?mode=none\"\n :sx-target \"#swap-log\" :sx-swap \"none\"\n \"Silent Ping\")"
:handler-code (handler-source "ex-swap-log") :handler-names (list "ex-swap-log")
:wire-placeholder-id "swap-wire")) :wire-placeholder-id "swap-wire"))
(defcomp ~examples-content/example-select-filter () (defcomp
~examples-content/example-select-filter
()
(~examples/page-content (~examples/page-content
:title "Select Filter" :title "Select Filter"
:description "sx-select lets the client pick a specific section from the server response by CSS selector. The server always returns the full dashboard — the client filters." :description "sx-select lets the client pick a specific section from the server response by CSS selector. The server always returns the full dashboard — the client filters."
:demo-description "Different buttons select different parts of the same server response." :demo-description "Different buttons select different parts of the same server response."
:demo (~examples/select-filter-demo) :demo (~examples/select-filter-demo)
:sx-code ";; Pick just the stats section from the response\n(button\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.dashboard))))\"\n :sx-target \"#filter-target\"\n :sx-swap \"innerHTML\"\n :sx-select \"#dash-stats\"\n \"Stats Only\")\n\n;; No sx-select — get the full response\n(button\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.dashboard))))\"\n :sx-target \"#filter-target\"\n :sx-swap \"innerHTML\"\n \"Full Dashboard\")" :sx-code ";; Pick just the stats section from the response\n(button\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.dashboard))))\"\n :sx-target \"#filter-target\"\n :sx-swap \"innerHTML\"\n :sx-select \"#dash-stats\"\n \"Stats Only\")\n\n;; No sx-select — get the full response\n(button\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.dashboard))))\"\n :sx-target \"#filter-target\"\n :sx-swap \"innerHTML\"\n \"Full Dashboard\")"
:handler-code (handler-source "ex-dashboard") :handler-names (list "ex-dashboard")
:wire-placeholder-id "filter-wire")) :wire-placeholder-id "filter-wire"))
(defcomp ~examples-content/example-tabs () (defcomp
~examples-content/example-tabs
()
(~examples/page-content (~examples/page-content
:title "Tabs" :title "Tabs"
:description "Tab navigation using sx-push-url to update the browser URL. Back/forward buttons navigate between previously visited tabs." :description "Tab navigation using sx-push-url to update the browser URL. Back/forward buttons navigate between previously visited tabs."
:demo-description "Click tabs to switch content. Watch the browser URL change." :demo-description "Click tabs to switch content. Watch the browser URL change."
:demo (~examples/tabs-demo) :demo (~examples/tabs-demo)
:sx-code "(button\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.(tabs.tab1)))))\"\n :sx-target \"#tab-content\"\n :sx-swap \"innerHTML\"\n :sx-push-url \"/sx/(geography.(hypermedia.(example.tabs)))?tab=tab1\"\n \"Overview\")" :sx-code "(button\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.(tabs.tab1)))))\"\n :sx-target \"#tab-content\"\n :sx-swap \"innerHTML\"\n :sx-push-url \"/sx/(geography.(hypermedia.(example.tabs)))?tab=tab1\"\n \"Overview\")"
:handler-code (handler-source "ex-tabs") :handler-names (list "ex-tabs")
:wire-placeholder-id "tabs-wire")) :wire-placeholder-id "tabs-wire"))
(defcomp ~examples-content/example-animations () (defcomp
~examples-content/example-animations
()
(~examples/page-content (~examples/page-content
:title "Animations" :title "Animations"
:description "CSS animations play on swap. The component injects a style tag with a keyframe animation and applies the class. Each click picks a random background colour." :description "CSS animations play on swap. The component injects a style tag with a keyframe animation and applies the class. Each click picks a random background colour."
:demo-description "Click to swap in content with a fade-in animation." :demo-description "Click to swap in content with a fade-in animation."
:demo (~examples/animations-demo) :demo (~examples/animations-demo)
:sx-code "(button\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.animate))))\"\n :sx-target \"#anim-target\"\n :sx-swap \"innerHTML\"\n \"Load with animation\")\n\n;; Component uses CSS animation class\n(defcomp ~examples-content/anim-result (&key color time)\n (div :class \"sx-fade-in ...\"\n (style \".sx-fade-in { animation: sxFadeIn 0.5s }\")\n (p \"Faded in!\")))" :sx-code "(button\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.animate))))\"\n :sx-target \"#anim-target\"\n :sx-swap \"innerHTML\"\n \"Load with animation\")\n\n;; Component uses CSS animation class\n(defcomp ~examples-content/anim-result (&key color time)\n (div :class \"sx-fade-in ...\"\n (style \".sx-fade-in { animation: sxFadeIn 0.5s }\")\n (p \"Faded in!\")))"
:handler-code (handler-source "ex-animate") :handler-names (list "ex-animate")
:comp-placeholder-id "anim-comp" :comp-placeholder-id "anim-comp"
:wire-placeholder-id "anim-wire")) :wire-placeholder-id "anim-wire"))
(defcomp ~examples-content/example-dialogs () (defcomp
~examples-content/example-dialogs
()
(~examples/page-content (~examples/page-content
:title "Dialogs" :title "Dialogs"
:description "Open a modal dialog by swapping in the dialog component. Close by swapping in empty content. Pure sx — no JavaScript library needed." :description "Open a modal dialog by swapping in the dialog component. Close by swapping in empty content. Pure sx — no JavaScript library needed."
:demo-description "Click to open a modal dialog." :demo-description "Click to open a modal dialog."
:demo (~examples/dialogs-demo) :demo (~examples/dialogs-demo)
:sx-code "(button\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.dialog))))\"\n :sx-target \"#dialog-container\"\n :sx-swap \"innerHTML\"\n \"Open Dialog\")\n\n;; Dialog closes by swapping empty content\n(button\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.dialog-close))))\"\n :sx-target \"#dialog-container\"\n :sx-swap \"innerHTML\"\n \"Close\")" :sx-code "(button\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.dialog))))\"\n :sx-target \"#dialog-container\"\n :sx-swap \"innerHTML\"\n \"Open Dialog\")\n\n;; Dialog closes by swapping empty content\n(button\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.dialog-close))))\"\n :sx-target \"#dialog-container\"\n :sx-swap \"innerHTML\"\n \"Close\")"
:handler-code (str (handler-source "ex-dialog") "\n\n" (handler-source "ex-dialog-close")) :handler-names (list "ex-dialog" "ex-dialog-close")
:comp-placeholder-id "dialog-comp" :comp-placeholder-id "dialog-comp"
:wire-placeholder-id "dialog-wire")) :wire-placeholder-id "dialog-wire"))
(defcomp ~examples-content/example-keyboard-shortcuts () (defcomp
~examples-content/example-keyboard-shortcuts
()
(~examples/page-content (~examples/page-content
:title "Keyboard Shortcuts" :title "Keyboard Shortcuts"
:description "Use sx-trigger with keyup event filters and from:body to listen for global keyboard shortcuts. The filter prevents firing when typing in inputs." :description "Use sx-trigger with keyup event filters and from:body to listen for global keyboard shortcuts. The filter prevents firing when typing in inputs."
:demo-description "Press s, n, or h on your keyboard." :demo-description "Press s, n, or h on your keyboard."
:demo (~examples/keyboard-shortcuts-demo) :demo (~examples/keyboard-shortcuts-demo)
:sx-code "(div :id \"kbd-target\"\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.keyboard))))?key=s\"\n :sx-trigger \"keyup[key=='s'&&!event.target.matches('input,textarea')] from:body\"\n :sx-swap \"innerHTML\"\n \"Press a shortcut key...\")" :sx-code "(div :id \"kbd-target\"\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.keyboard))))?key=s\"\n :sx-trigger \"keyup[key=='s'&&!event.target.matches('input,textarea')] from:body\"\n :sx-swap \"innerHTML\"\n \"Press a shortcut key...\")"
:handler-code (handler-source "ex-keyboard") :handler-names (list "ex-keyboard")
:comp-placeholder-id "kbd-comp" :comp-placeholder-id "kbd-comp"
:wire-placeholder-id "kbd-wire")) :wire-placeholder-id "kbd-wire"))
(defcomp ~examples-content/example-put-patch () (defcomp
~examples-content/example-put-patch
()
(~examples/page-content (~examples/page-content
:title "PUT / PATCH" :title "PUT / PATCH"
:description "sx-put replaces the entire resource. This example shows a profile card with an Edit All button that sends a PUT with all fields." :description "sx-put replaces the entire resource. This example shows a profile card with an Edit All button that sends a PUT with all fields."
:demo-description "Click Edit All to replace the full profile via PUT." :demo-description "Click Edit All to replace the full profile via PUT."
:demo (~examples/put-patch-demo :name "Ada Lovelace" :email "ada@example.com" :role "Engineer") :demo (~examples/put-patch-demo
:name "Ada Lovelace"
:email "ada@example.com"
:role "Engineer")
:sx-code ";; Replace entire resource\n(form :sx-put \"/sx/(geography.(hypermedia.(example.(api.putpatch))))\"\n :sx-target \"#pp-target\" :sx-swap \"innerHTML\"\n (input :name \"name\") (input :name \"email\")\n (button \"Save All (PUT)\"))" :sx-code ";; Replace entire resource\n(form :sx-put \"/sx/(geography.(hypermedia.(example.(api.putpatch))))\"\n :sx-target \"#pp-target\" :sx-swap \"innerHTML\"\n (input :name \"name\") (input :name \"email\")\n (button \"Save All (PUT)\"))"
:handler-code (str (handler-source "ex-pp-edit-all") "\n\n" (handler-source "ex-pp-put")) :handler-names (list "ex-pp-edit-all" "ex-pp-put")
:comp-placeholder-id "pp-comp" :comp-placeholder-id "pp-comp"
:wire-placeholder-id "pp-wire")) :wire-placeholder-id "pp-wire"))
(defcomp ~examples-content/example-json-encoding () (defcomp
~examples-content/example-json-encoding
()
(~examples/page-content (~examples/page-content
:title "JSON Encoding" :title "JSON Encoding"
:description "Use sx-encoding=\"json\" to send form data as a JSON body instead of URL-encoded form data. The server echoes back what it received." :description "Use sx-encoding=\"json\" to send form data as a JSON body instead of URL-encoded form data. The server echoes back what it received."
:demo-description "Submit the form and see the JSON body the server received." :demo-description "Submit the form and see the JSON body the server received."
:demo (~examples/json-encoding-demo) :demo (~examples/json-encoding-demo)
:sx-code "(form\n :sx-post \"/sx/(geography.(hypermedia.(example.(api.json-echo))))\"\n :sx-target \"#json-result\"\n :sx-swap \"innerHTML\"\n :sx-encoding \"json\"\n (input :name \"name\" :value \"Ada\")\n (input :type \"number\" :name \"age\" :value \"36\")\n (button \"Submit as JSON\"))" :sx-code "(form\n :sx-post \"/sx/(geography.(hypermedia.(example.(api.json-echo))))\"\n :sx-target \"#json-result\"\n :sx-swap \"innerHTML\"\n :sx-encoding \"json\"\n (input :name \"name\" :value \"Ada\")\n (input :type \"number\" :name \"age\" :value \"36\")\n (button \"Submit as JSON\"))"
:handler-code (handler-source "ex-json-echo") :handler-names (list "ex-json-echo")
:comp-placeholder-id "json-comp" :comp-placeholder-id "json-comp"
:wire-placeholder-id "json-wire")) :wire-placeholder-id "json-wire"))
(defcomp ~examples-content/example-vals-and-headers () (defcomp
~examples-content/example-vals-and-headers
()
(~examples/page-content (~examples/page-content
:title "Vals & Headers" :title "Vals & Headers"
:description "sx-vals adds extra key/value pairs to the request parameters. sx-headers adds custom HTTP headers. The server echoes back what it received." :description "sx-vals adds extra key/value pairs to the request parameters. sx-headers adds custom HTTP headers. The server echoes back what it received."
:demo-description "Click each button to see what the server receives." :demo-description "Click each button to see what the server receives."
:demo (~examples/vals-headers-demo) :demo (~examples/vals-headers-demo)
:sx-code ";; Send extra values with the request\n(button\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.echo-vals))))\"\n :sx-vals \"{\\\"source\\\": \\\"button\\\"}\"\n \"Send with vals\")\n\n;; Send custom headers\n(button\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.echo-headers))))\"\n :sx-headers {:X-Custom-Token \"abc123\"}\n \"Send with headers\")" :sx-code ";; Send extra values with the request\n(button\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.echo-vals))))\"\n :sx-vals \"{\\\"source\\\": \\\"button\\\"}\"\n \"Send with vals\")\n\n;; Send custom headers\n(button\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.echo-headers))))\"\n :sx-headers {:X-Custom-Token \"abc123\"}\n \"Send with headers\")"
:handler-code (str (handler-source "ex-echo-vals") "\n\n" (handler-source "ex-echo-headers")) :handler-names (list "ex-echo-vals" "ex-echo-headers")
:comp-placeholder-id "vals-comp" :comp-placeholder-id "vals-comp"
:wire-placeholder-id "vals-wire")) :wire-placeholder-id "vals-wire"))
(defcomp ~examples-content/example-loading-states () (defcomp
~examples-content/example-loading-states
()
(~examples/page-content (~examples/page-content
:title "Loading States" :title "Loading States"
:description "sx.js adds the .sx-request CSS class to any element that has an active request. Use pure CSS to show spinners, disable buttons, or change opacity during loading." :description "sx.js adds the .sx-request CSS class to any element that has an active request. Use pure CSS to show spinners, disable buttons, or change opacity during loading."
:demo-description "Click the button — it shows a spinner during the 2-second request." :demo-description "Click the button — it shows a spinner during the 2-second request."
:demo (~examples/loading-states-demo) :demo (~examples/loading-states-demo)
:sx-code ";; .sx-request class added during request\n(style \".sx-loading-btn.sx-request {\n opacity: 0.7; pointer-events: none; }\n.sx-loading-btn.sx-request .sx-spinner {\n display: inline-block; }\n.sx-loading-btn .sx-spinner {\n display: none; }\")\n\n(button :class \"sx-loading-btn\"\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.slow))))\"\n :sx-target \"#loading-result\"\n (span :class \"sx-spinner animate-spin\" \"...\")\n \"Load slow endpoint\")" :sx-code ";; .sx-request class added during request\n(style \".sx-loading-btn.sx-request {\n opacity: 0.7; pointer-events: none; }\n.sx-loading-btn.sx-request .sx-spinner {\n display: inline-block; }\n.sx-loading-btn .sx-spinner {\n display: none; }\")\n\n(button :class \"sx-loading-btn\"\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.slow))))\"\n :sx-target \"#loading-result\"\n (span :class \"sx-spinner animate-spin\" \"...\")\n \"Load slow endpoint\")"
:handler-code (handler-source "ex-slow") :handler-names (list "ex-slow")
:comp-placeholder-id "loading-comp" :comp-placeholder-id "loading-comp"
:wire-placeholder-id "loading-wire")) :wire-placeholder-id "loading-wire"))
(defcomp ~examples-content/example-sync-replace () (defcomp
~examples-content/example-sync-replace
()
(~examples/page-content (~examples/page-content
:title "Request Abort" :title "Request Abort"
:description "sx-sync=\"replace\" aborts any in-flight request before sending a new one. This prevents stale responses from overwriting newer ones, even with random server delays." :description "sx-sync=\"replace\" aborts any in-flight request before sending a new one. This prevents stale responses from overwriting newer ones, even with random server delays."
:demo-description "Type quickly — only the latest result appears despite random 0.5-2s server delays." :demo-description "Type quickly — only the latest result appears despite random 0.5-2s server delays."
:demo (~examples/sync-replace-demo) :demo (~examples/sync-replace-demo)
:sx-code "(input :type \"text\" :name \"q\"\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.slow-search))))\"\n :sx-trigger \"keyup delay:200ms changed\"\n :sx-target \"#sync-result\"\n :sx-swap \"innerHTML\"\n :sx-sync \"replace\"\n \"Type to search...\")" :sx-code "(input :type \"text\" :name \"q\"\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.slow-search))))\"\n :sx-trigger \"keyup delay:200ms changed\"\n :sx-target \"#sync-result\"\n :sx-swap \"innerHTML\"\n :sx-sync \"replace\"\n \"Type to search...\")"
:handler-code (handler-source "ex-slow-search") :handler-names (list "ex-slow-search")
:comp-placeholder-id "sync-comp" :comp-placeholder-id "sync-comp"
:wire-placeholder-id "sync-wire")) :wire-placeholder-id "sync-wire"))
(defcomp ~examples-content/example-retry () (defcomp
~examples-content/example-retry
()
(~examples/page-content (~examples/page-content
:title "Retry" :title "Retry"
:description "sx-retry=\"exponential:1000:8000\" retries failed requests with exponential backoff starting at 1s up to 8s. The endpoint fails the first 2 attempts and succeeds on the 3rd." :description "sx-retry=\"exponential:1000:8000\" retries failed requests with exponential backoff starting at 1s up to 8s. The endpoint fails the first 2 attempts and succeeds on the 3rd."
:demo-description "Click the button — watch it retry automatically after failures." :demo-description "Click the button — watch it retry automatically after failures."
:demo (~examples/retry-demo) :demo (~examples/retry-demo)
:sx-code "(button\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.flaky))))\"\n :sx-target \"#retry-result\"\n :sx-swap \"innerHTML\"\n :sx-retry \"exponential:1000:8000\"\n \"Call flaky endpoint\")" :sx-code "(button\n :sx-get \"/sx/(geography.(hypermedia.(example.(api.flaky))))\"\n :sx-target \"#retry-result\"\n :sx-swap \"innerHTML\"\n :sx-retry \"exponential:1000:8000\"\n \"Call flaky endpoint\")"
:handler-code (handler-source "ex-flaky") :handler-names (list "ex-flaky")
:comp-placeholder-id "retry-comp" :comp-placeholder-id "retry-comp"
:wire-placeholder-id "retry-wire")) :wire-placeholder-id "retry-wire"))

View File

@@ -7,7 +7,7 @@
demo demo
(sx-code :as string) (sx-code :as string)
(sx-lang :as string?) (sx-lang :as string?)
(handler-code :as string) (handler-names :as list)
(handler-lang :as string?) (handler-lang :as string?)
(comp-placeholder-id :as string?) (comp-placeholder-id :as string?)
(wire-placeholder-id :as string?) (wire-placeholder-id :as string?)
@@ -17,31 +17,38 @@
(~docs/page (~docs/page
:title title :title title
(p :class "text-stone-600 mb-6" description) (p :class "text-stone-600 mb-6" description)
(~examples/card (error-boundary
:title "Demo" (~examples/card
:description demo-description :title "Demo"
(~examples/demo demo)) :description demo-description
(h3 :class "text-lg font-semibold text-stone-700 mt-6" "S-expression") (~examples/demo demo)))
(~examples/source (h3 :class "text-lg font-semibold text-stone-700 mt-8 mb-3" "S-expression")
:src-code (highlight sx-code (if sx-lang sx-lang "lisp"))) (error-boundary
(~examples/source
:src-code (highlight sx-code (if sx-lang sx-lang "lisp"))))
(when (when
comp-placeholder-id comp-placeholder-id
(<> (<>
(h3 (h3
:class "text-lg font-semibold text-stone-700 mt-6" :class "text-lg font-semibold text-stone-700 mt-8 mb-3"
(if comp-heading comp-heading "Component")) (if comp-heading comp-heading "Component"))
(~docs/placeholder :id comp-placeholder-id))) (error-boundary (~docs/placeholder :id comp-placeholder-id))))
(h3 (h3
:class "text-lg font-semibold text-stone-700 mt-6" :class "text-lg font-semibold text-stone-700 mt-8 mb-3"
(if handler-heading handler-heading "Server handler")) (if handler-heading handler-heading "Server handler"))
(~examples/source (error-boundary
:src-code (highlight handler-code (if handler-lang handler-lang "python"))) (~examples/source
:src-code (highlight
(join "\n\n" (map handler-source handler-names))
(if handler-lang handler-lang "python"))))
(div (div
:class "flex items-center justify-between mt-6" :class "flex items-center justify-between mt-6"
(h3 :class "text-lg font-semibold text-stone-700" "Wire response") (h3 :class "text-lg font-semibold text-stone-700" "Wire response")
(~docs/clear-cache-btn)) (~docs/clear-cache-btn))
(when wire-note (p :class "text-stone-500 text-sm mb-2" wire-note)) (when wire-note (p :class "text-stone-500 text-sm mb-2" wire-note))
(when wire-placeholder-id (~docs/placeholder :id wire-placeholder-id)))) (when
wire-placeholder-id
(error-boundary (~docs/placeholder :id wire-placeholder-id)))))
(defcomp (defcomp
~examples/reference-index-content ~examples/reference-index-content

View File

@@ -1,90 +1,100 @@
// SPA navigation tests — verify header, nav, and content survive navigation // SPA navigation tests — verify header, nav, and content survive navigation
// Tests that OOB swaps update nav while preserving the header island, // Tests that OOB swaps update nav while preserving the header island,
// and that rendering errors are scoped to #sx-content. // that sx-select extracts content without nesting, and that rendering
// errors are scoped to #sx-content via error-boundary.
const { test, expect } = require('playwright/test'); const { test, expect } = require('playwright/test');
const BASE_URL = process.env.SX_TEST_URL || 'http://localhost:8013'; const BASE_URL = process.env.SX_TEST_URL || 'http://localhost:8013';
test.describe('SPA navigation', () => { test.describe('SPA navigation', () => {
test('header island survives SPA nav to sibling page', async ({ page }) => { test('no #sx-content nesting after SPA nav', async ({ page }) => {
await page.goto(BASE_URL + '/sx/(geography.(hypermedia.(example)))', { waitUntil: 'networkidle' }); await page.goto(BASE_URL + '/sx/(geography)', { waitUntil: 'networkidle' });
await expect(page.locator('#sx-content')).toHaveCount(1);
// Header should be present and hydrated // Navigate via SPA link to Reactive Islands
const headerIsland = page.locator('[data-sx-island="layouts/header"]'); await page.click('a[sx-get*="(geography.(reactive))"]');
await expect(headerIsland).toHaveCount(1); await page.waitForTimeout(4000);
await expect(headerIsland).toContainText('sx');
// Navigate via SPA // Must still be exactly 1 #sx-content — no nesting
await page.click('a[href*="click-to-load"]'); await expect(page.locator('#sx-content')).toHaveCount(1);
await page.waitForURL('**/click-to-load**'); });
await page.waitForTimeout(2000);
test('content renders after SPA nav (not empty boundary)', async ({ page }) => {
await page.goto(BASE_URL + '/sx/(geography)', { waitUntil: 'networkidle' });
await page.click('a[sx-get*="(geography.(reactive))"]');
await page.waitForTimeout(4000);
// Content should have actual page text
await expect(page.locator('#sx-content')).toContainText('Architecture', { timeout: 5000 });
});
test('nav updates via OOB on SPA navigation', async ({ page }) => {
await page.goto(BASE_URL + '/sx/(geography)', { waitUntil: 'networkidle' });
await expect(page.locator('#sx-nav')).toContainText('Geography');
await page.click('a[sx-get*="(geography.(reactive))"]');
await page.waitForTimeout(4000);
// Nav should update to show the new page
await expect(page.locator('#sx-nav')).toContainText('Reactive Islands');
});
test('header island survives SPA nav', async ({ page }) => {
await page.goto(BASE_URL + '/sx/(geography)', { waitUntil: 'networkidle' });
await expect(page.locator('[data-sx-island="layouts/header"]')).toHaveCount(1);
await page.click('a[sx-get*="(geography.(reactive))"]');
await page.waitForTimeout(4000);
// Header island should still exist
await expect(page.locator('[data-sx-island="layouts/header"]')).toHaveCount(1); await expect(page.locator('[data-sx-island="layouts/header"]')).toHaveCount(1);
await expect(page.locator('[data-sx-island="layouts/header"]')).toContainText('sx'); await expect(page.locator('[data-sx-island="layouts/header"]')).toContainText('sx');
}); });
test('nav updates via OOB on SPA navigation', async ({ page }) => { test('nav updates even when content has render error', async ({ page }) => {
await page.goto(BASE_URL + '/sx/(geography.(hypermedia.(example)))', { waitUntil: 'networkidle' }); await page.goto(BASE_URL + '/sx/(geography.(hypermedia.(example)))', { waitUntil: 'networkidle' });
await expect(page.locator('#sx-nav')).toContainText('Examples');
// Nav should show current breadcrumbs await page.click('a[sx-get*="click-to-load"]');
const nav = page.locator('#sx-nav'); await page.waitForTimeout(4000);
await expect(nav).toHaveCount(1);
const navTextBefore = await nav.textContent();
expect(navTextBefore).toContain('Examples');
// Navigate to a child page // Nav should still update despite content error
await page.click('a[href*="click-to-load"]'); await expect(page.locator('#sx-nav')).toContainText('Click to Load');
await page.waitForURL('**/click-to-load**');
await page.waitForTimeout(2000);
// Nav should update to show the new page in breadcrumbs
const navTextAfter = await page.locator('#sx-nav').first().textContent();
expect(navTextAfter).toContain('Click to Load');
}); });
test('#sx-content exists after SPA navigation', async ({ page }) => { test('render error scoped to #sx-content via error-boundary', async ({ page }) => {
await page.goto(BASE_URL + '/sx/(geography.(hypermedia.(example)))', { waitUntil: 'networkidle' }); await page.goto(BASE_URL + '/sx/(geography.(hypermedia.(example)))', { waitUntil: 'networkidle' });
await expect(page.locator('#sx-content')).toHaveCount(1);
await page.click('a[href*="click-to-load"]'); await page.click('a[sx-get*="click-to-load"]');
await page.waitForURL('**/click-to-load**'); await page.waitForTimeout(4000);
await page.waitForTimeout(2000);
// sx-content should still exist (may contain error boundary, but not be missing) // Error should be inside the error boundary within #sx-content
await expect(page.locator('#sx-content').first()).toBeAttached(); const errors = page.locator('#sx-content .sx-render-error');
await expect(errors).toHaveCount(1);
await expect(errors).toContainText('Render error');
// Header should still be intact
await expect(page.locator('[data-sx-island="layouts/header"]')).toHaveCount(1);
}); });
test('rendering error scoped to #sx-content, not full page', async ({ page }) => { test('multiple SPA navigations maintain single #sx-content', async ({ page }) => {
await page.goto(BASE_URL + '/sx/(geography.(hypermedia.(example)))', { waitUntil: 'networkidle' }); await page.goto(BASE_URL + '/sx/(geography)', { waitUntil: 'networkidle' });
// Verify page structure before nav
await expect(page.locator('#sx-nav')).toHaveCount(1);
await expect(page.locator('#sx-content')).toHaveCount(1); await expect(page.locator('#sx-content')).toHaveCount(1);
await page.click('a[href*="click-to-load"]'); // First navigation
await page.waitForURL('**/click-to-load**'); await page.click('a[sx-get*="(geography.(reactive))"]');
await page.waitForTimeout(2000); await page.waitForTimeout(4000);
await expect(page.locator('#sx-content')).toHaveCount(1);
await expect(page.locator('#sx-nav')).toContainText('Reactive Islands');
// If there's a render error, it should be inside #sx-content // Navigate back to Geography
const errors = page.locator('.sx-render-error'); const geoLink = page.locator('a[sx-get="/sx/(geography)"]');
const errorCount = await errors.count(); if (await geoLink.count() > 0) {
if (errorCount > 0) { await geoLink.first().click();
// Error should be a descendant of #sx-content, not replacing the whole page await page.waitForTimeout(4000);
const errorParent = await errors.first().evaluate(el => { await expect(page.locator('#sx-content')).toHaveCount(1);
let p = el; await expect(page.locator('#sx-nav')).toContainText('Geography');
while (p) {
if (p.id === 'sx-content') return 'sx-content';
if (p.id === 'main-panel') return 'main-panel';
p = p.parentElement;
}
return 'unknown';
});
expect(errorParent).toBe('sx-content');
// Nav should still be present even with an error
await expect(page.locator('#sx-nav').first()).toContainText('Click to Load');
} }
}); });
}); });

View File

@@ -1302,8 +1302,8 @@
(fn (fn
((args :as list) (env :as dict) (ns :as string)) ((args :as list) (env :as dict) (ns :as string))
(let (let
((fallback-expr (first args)) ((fallback-expr (if (> (len args) 1) (first args) nil))
(body-exprs (rest args)) (body-exprs (if (> (len args) 1) (rest args) args))
(container (dom-create-element "div" nil)) (container (dom-create-element "div" nil))
(retry-version (signal 0))) (retry-version (signal 0)))
(dom-set-attr container "data-sx-boundary" "true") (dom-set-attr container "data-sx-boundary" "true")
@@ -1333,6 +1333,6 @@
(retry-fn (retry-fn
(fn () (swap! retry-version (fn (n) (+ n 1)))))) (fn () (swap! retry-version (fn (n) (+ n 1))))))
(let (let
((fallback-dom (if (lambda? fallback-fn) (render-lambda-dom fallback-fn (list err retry-fn) env ns) (render-to-dom (apply fallback-fn (list err retry-fn)) env ns)))) ((fallback-dom (if (nil? fallback-fn) (let ((el (dom-create-element "div" nil))) (dom-set-attr el "class" "sx-render-error") (dom-set-attr el "style" "color:red;font-size:0.875rem;padding:0.5rem;border:1px solid red;border-radius:0.25rem;margin:0.5rem 0;") (dom-set-text-content el (str "Render error: " err)) el) (if (lambda? fallback-fn) (render-lambda-dom fallback-fn (list err retry-fn) env ns) (render-to-dom (apply fallback-fn (list err retry-fn)) env ns)))))
(dom-append container fallback-dom))))))) (dom-append container fallback-dom)))))))
container))) container)))

View File

@@ -110,10 +110,47 @@
(render-html-lake args env) (render-html-lake args env)
(= name "marsh") (= name "marsh")
(render-html-marsh args env) (render-html-marsh args env)
(or (= name "error-boundary")
(= name "portal") (let
(= name "error-boundary") ((has-fallback (> (len args) 1)))
(= name "promise-delayed")) (let
((body-exprs (if has-fallback (rest args) args))
(fallback-expr (if has-fallback (first args) nil)))
(str
"<div data-sx-boundary=\"true\">"
(try-catch
(fn
()
(join
""
(map (fn (x) (render-to-html x env)) body-exprs)))
(fn
(err)
(let
((safe-err (replace (replace (str err) "<" "&lt;") ">" "&gt;")))
(if
(and fallback-expr (not (nil? fallback-expr)))
(try-catch
(fn
()
(render-to-html
(list
(trampoline (eval-expr fallback-expr env))
err
nil)
env))
(fn
(e2)
(str
"<div class=\"sx-render-error\" style=\"color:red;font-size:0.875rem;padding:0.5rem;border:1px solid red;border-radius:0.25rem;margin:0.5rem 0;\">Render error: "
safe-err
"</div>")))
(str
"<div class=\"sx-render-error\" style=\"color:red;font-size:0.875rem;padding:0.5rem;border:1px solid red;border-radius:0.25rem;margin:0.5rem 0;\">Render error: "
safe-err
"</div>")))))
"</div>")))
(or (= name "portal") (= name "promise-delayed"))
(join "" (map (fn (x) (render-to-html x env)) args)) (join "" (map (fn (x) (render-to-html x env)) args))
(contains? HTML_TAGS name) (contains? HTML_TAGS name)
(render-html-element name args env) (render-html-element name args env)

View File

@@ -66,7 +66,24 @@
(= name "marsh") (= name "marsh")
(aser-call name args env) (aser-call name args env)
(= name "error-boundary") (= name "error-boundary")
(aser-call name args env) (let
((has-fallback (> (len args) 1)))
(let
((body-exprs (if has-fallback (rest args) args))
(err-str nil))
(let
((rendered (try-catch (fn () (join "" (map (fn (x) (let ((v (aser x env))) (cond (= (type-of v) "sx-expr") (sx-expr-source v) (nil? v) "" :else (serialize v)))) body-exprs))) (fn (err) (set! err-str (str err)) nil))))
(if
rendered
(make-sx-expr (str "(error-boundary " rendered ")"))
(make-sx-expr
(str
"(div :data-sx-boundary \"true\" "
"(div :class \"sx-render-error\" "
":style \"color:red;font-size:0.875rem;padding:0.5rem;border:1px solid red;border-radius:0.25rem;margin:0.5rem 0;\" "
"\"Render error: "
(replace (replace err-str "\"" "'") "\\" "\\\\")
"\"))"))))))
(contains? HTML_TAGS name) (contains? HTML_TAGS name)
(aser-call name args env) (aser-call name args env)
(or (special-form? name) (ho-form? name)) (or (special-form? name) (ho-form? name))

View File

@@ -288,24 +288,21 @@
s) s)
(post-swap t))) (post-swap t)))
(let (let
((select-sel (dom-get-attr el "sx-select")) ((select-sel (dom-get-attr el "sx-select")))
(content (let
(if ((content (if select-sel (select-from-container container select-sel) (children-to-fragment container))))
select-sel (dispose-islands-in target)
(select-from-container container select-sel) (with-transition
(children-to-fragment container)))) use-transition
(dispose-islands-in target) (fn
(with-transition ()
use-transition (let
(fn ((swap-result (swap-dom-nodes target content swap-style)))
() (post-swap
(let (if
((swap-result (swap-dom-nodes target content swap-style))) (= swap-style "outerHTML")
(post-swap (dom-parent (or swap-result target))
(if (or swap-result target)))))))))))))))
(= swap-style "outerHTML")
(dom-parent (or swap-result target))
(or swap-result target))))))))))))))
(define (define
handle-html-response handle-html-response
@@ -973,17 +970,35 @@
:effects (mutation io) :effects (mutation io)
(fn (fn
(target rendered (pathname :as string)) (target rendered (pathname :as string))
(do (let
(dispose-islands-in target) ((container (dom-create-element "div" nil)))
(dom-set-text-content target "") (dom-append container rendered)
(dom-append target rendered) (process-oob-swaps
(hoist-head-elements-full target) container
(process-elements target) (fn
(sx-hydrate-elements target) (t oob (s :as string))
(sx-hydrate-islands target) (dispose-islands-in t)
(run-post-render-hooks) (swap-dom-nodes
(dom-dispatch target "sx:clientRoute" (dict "pathname" pathname)) t
(log-info (str "sx:route client " pathname))))) (if (= s "innerHTML") (children-to-fragment oob) oob)
s)
(post-swap t)))
(let
((target-id (dom-get-attr target "id")))
(let
((inner (if target-id (dom-query container (str "#" target-id)) nil)))
(let
((content (if inner (children-to-fragment inner) (children-to-fragment container))))
(dispose-islands-in target)
(dom-set-text-content target "")
(dom-append target content)
(hoist-head-elements-full target)
(process-elements target)
(sx-hydrate-elements target)
(sx-hydrate-islands target)
(run-post-render-hooks)
(dom-dispatch target "sx:clientRoute" (dict "pathname" pathname))
(log-info (str "sx:route client " pathname))))))))
(define (define
resolve-route-target resolve-route-target