Add inline test runner for temperature converter demo
Temperature converter tests (6 tests): initial value, computed fahrenheit derivation, +5/-5 click handlers, reactive propagation, multiple click accumulation. New components: - sx/sx/reactive-islands/test-runner.sx — reusable defisland that parses test source, runs defsuite/deftest forms via cek-eval, and displays pass/fail results with re-run button - sx/sx/reactive-islands/test-temperature.sx — standalone test file Added cek-try primitive to both browser (sx_browser.ml) and server (sx_server.ml) for safe test execution with error catching. Browser bundle now includes harness files (harness.sx, harness-reactive.sx, harness-web.sx) for inline test execution. Known: SSR renders test runner body instead of placeholder, causing arity error on complex str expressions. Needs island SSR handling fix. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,218 +1,35 @@
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Examples — individual reactive island demo pages
|
||||
;; ---------------------------------------------------------------------------
|
||||
(defcomp ~reactive-islands/demo/reactive-islands-demo-content () (~docs/page :title "Reactive Islands — Examples" (~docs/section :title "Live interactive islands" :id "intro" (p (strong "Every example below is a live interactive island") " — not a static code snippet. Click the buttons, type in the inputs. The signal runtime is defined in " (code "signals.sx") ", bootstrapped to JavaScript. No hand-written signal logic.") (p "Each island uses " (code "defisland") " with signals (" (code "signal") ", " (code "deref") ", " (code "reset!") ", " (code "swap!") "), derived values (" (code "computed") "), side effects (" (code "effect") "), and batch updates (" (code "batch") ").")) (~docs/section :title "Examples" :id "examples" (ol :class "space-y-1" (map (fn (item) (li (a :href (get item "href") :sx-get (get item "href") :sx-target "#main-panel" :sx-select "#main-panel" :sx-swap "outerHTML" :sx-push-url "true" :class "text-violet-600 hover:underline" (get item "label")))) reactive-examples-nav-items)))))
|
||||
|
||||
;; Overview page — summary with links to individual examples
|
||||
(defcomp ~reactive-islands/demo/reactive-islands-demo-content ()
|
||||
(~docs/page :title "Reactive Islands — Examples"
|
||||
(~docs/section :title "Live interactive islands" :id "intro"
|
||||
(p (strong "Every example below is a live interactive island") " — not a static code snippet. Click the buttons, type in the inputs. The signal runtime is defined in " (code "signals.sx") ", bootstrapped to JavaScript. No hand-written signal logic.")
|
||||
(p "Each island uses " (code "defisland") " with signals (" (code "signal") ", " (code "deref") ", " (code "reset!") ", " (code "swap!") "), derived values (" (code "computed") "), side effects (" (code "effect") "), and batch updates (" (code "batch") ")."))
|
||||
(~docs/section :title "Examples" :id "examples"
|
||||
(ol :class "space-y-1"
|
||||
(map (fn (item)
|
||||
(li (a :href (get item "href")
|
||||
:sx-get (get item "href") :sx-target "#main-panel"
|
||||
:sx-select "#main-panel" :sx-swap "outerHTML"
|
||||
:sx-push-url "true"
|
||||
:class "text-violet-600 hover:underline"
|
||||
(get item "label"))))
|
||||
reactive-examples-nav-items)))))
|
||||
(defcomp ~reactive-islands/demo/example-counter () (~docs/page :title "Signal + Computed + Effect" (p "A signal holds a value. A computed derives from it. Click the buttons — the counter and doubled value update instantly, no server round-trip.") (~reactive-islands/index/demo-counter :initial 0) (~docs/code :src (highlight "(defisland ~reactive-islands/demo/counter (&key initial)\n (let ((count (signal (or initial 0)))\n (doubled (computed (fn () (* 2 (deref count))))))\n (div :class \"...\"\n (button :on-click (fn (e) (swap! count dec)) \"−\")\n (span (deref count))\n (button :on-click (fn (e) (swap! count inc)) \"+\")\n (p \"doubled: \" (deref doubled)))))" "lisp")) (p (code "(deref count)") " in a text position creates a reactive text node. When " (code "count") " changes, " (em "only that text node") " updates. " (code "doubled") " recomputes automatically. No diffing.")))
|
||||
|
||||
;; ---------------------------------------------------------------------------
|
||||
;; Individual example pages
|
||||
;; ---------------------------------------------------------------------------
|
||||
(defcomp ~reactive-islands/demo/example-temperature () (~docs/page :title "Temperature Converter" (p "Two derived values from one signal. Click to change Celsius — Fahrenheit updates reactively.") (~reactive-islands/index/demo-temperature) (~docs/code :src (highlight "(defisland ~reactive-islands/demo/temperature ()\n (let ((celsius (signal 20)))\n (div :class \"...\"\n (button :on-click (fn (e) (swap! celsius (fn (c) (- c 5)))) \"−5\")\n (span (deref celsius))\n (button :on-click (fn (e) (swap! celsius (fn (c) (+ c 5)))) \"+5\")\n (span \"°C = \")\n (span (+ (* (deref celsius) 1.8) 32))\n (span \"°F\"))))" "lisp")) (p "The actual implementation uses " (code "computed") " for Fahrenheit: " (code "(computed (fn () (+ (* (deref celsius) 1.8) 32)))") ". The " (code "(deref fahrenheit)") " in the span creates a reactive text node that updates when celsius changes.") (~reactive-islands/test-runner :test-src (str "(defsuite \"temperature converter\"\n" " (deftest \"initial celsius is 20\"\n" " (let ((celsius (signal 20)))\n" " (assert-signal-value celsius 20)))\n" " (deftest \"computed fahrenheit derives from celsius\"\n" " (let ((celsius (signal 20))\n" " (fahrenheit (computed (fn () (+ (* (deref celsius) 1.8) 32)))))\n" " (assert-signal-value fahrenheit 68)\n" " (assert-computed-depends-on fahrenheit celsius)))\n" " (deftest \"+5 increments celsius\"\n" " (let ((celsius (signal 20))\n" " (btn (mock-element \"button\")))\n" " (mock-add-listener! btn \"click\"\n" " (fn (e) (swap! celsius (fn (c) (+ c 5)))))\n" " (simulate-click btn)\n" " (assert-signal-value celsius 25)))\n" " (deftest \"−5 decrements celsius\"\n" " (let ((celsius (signal 20))\n" " (btn (mock-element \"button\")))\n" " (mock-add-listener! btn \"click\"\n" " (fn (e) (swap! celsius (fn (c) (- c 5)))))\n" " (simulate-click btn)\n" " (assert-signal-value celsius 15)))\n" " (deftest \"fahrenheit updates on celsius change\"\n" " (let ((celsius (signal 20))\n" " (fahrenheit (computed (fn () (+ (* (deref celsius) 1.8) 32)))))\n" " (reset! celsius 0)\n" " (assert-signal-value fahrenheit 32)\n" " (reset! celsius 100)\n" " (assert-signal-value fahrenheit 212)))\n" " (deftest \"multiple clicks accumulate\"\n" " (let ((celsius (signal 20))\n" " (fahrenheit (computed (fn () (+ (* (deref celsius) 1.8) 32))))\n" " (btn (mock-element \"button\")))\n" " (mock-add-listener! btn \"click\"\n" " (fn (e) (swap! celsius (fn (c) (+ c 5)))))\n" " (simulate-click btn)\n" " (simulate-click btn)\n" " (simulate-click btn)\n" " (assert-signal-value celsius 35)\n" " (assert-signal-value fahrenheit 95))))"))))
|
||||
|
||||
(defcomp ~reactive-islands/demo/example-counter ()
|
||||
(~docs/page :title "Signal + Computed + Effect"
|
||||
(p "A signal holds a value. A computed derives from it. Click the buttons — the counter and doubled value update instantly, no server round-trip.")
|
||||
(~reactive-islands/index/demo-counter :initial 0)
|
||||
(~docs/code :src (highlight "(defisland ~reactive-islands/demo/counter (&key initial)\n (let ((count (signal (or initial 0)))\n (doubled (computed (fn () (* 2 (deref count))))))\n (div :class \"...\"\n (button :on-click (fn (e) (swap! count dec)) \"−\")\n (span (deref count))\n (button :on-click (fn (e) (swap! count inc)) \"+\")\n (p \"doubled: \" (deref doubled)))))" "lisp"))
|
||||
(p (code "(deref count)") " in a text position creates a reactive text node. When " (code "count") " changes, " (em "only that text node") " updates. " (code "doubled") " recomputes automatically. No diffing.")))
|
||||
(defcomp ~reactive-islands/demo/example-stopwatch () (~docs/page :title "Effect + Cleanup: Stopwatch" (p "Effects can return cleanup functions. This stopwatch starts a " (code "set-interval") " — the cleanup clears it when the running signal toggles off.") (~reactive-islands/index/demo-stopwatch) (~docs/code :src (highlight "(defisland ~reactive-islands/demo/stopwatch ()\n (let ((running (signal false))\n (elapsed (signal 0))\n (time-text (create-text-node \"0.0s\"))\n (btn-text (create-text-node \"Start\")))\n ;; Timer: effect creates interval, cleanup clears it\n (effect (fn ()\n (when (deref running)\n (let ((id (set-interval (fn () (swap! elapsed inc)) 100)))\n (fn () (clear-interval id))))))\n ;; Display: updates text node when elapsed changes\n (effect (fn ()\n (let ((e (deref elapsed)))\n (dom-set-text-content time-text\n (str (floor (/ e 10)) \".\" (mod e 10) \"s\")))))\n ;; Button label\n (effect (fn ()\n (dom-set-text-content btn-text\n (if (deref running) \"Stop\" \"Start\"))))\n (div :class \"...\"\n (span time-text)\n (button :on-click (fn (e) (swap! running not)) btn-text)\n (button :on-click (fn (e)\n (reset! running false) (reset! elapsed 0)) \"Reset\"))))" "lisp")) (p "Three effects, each tracking different signals. The timer effect's cleanup fires before each re-run — toggling " (code "running") " off clears the interval. No hook rules: effects can appear anywhere, in any order.")))
|
||||
|
||||
(defcomp ~reactive-islands/demo/example-temperature ()
|
||||
(~docs/page :title "Temperature Converter"
|
||||
(p "Two derived values from one signal. Click to change Celsius — Fahrenheit updates reactively.")
|
||||
(~reactive-islands/index/demo-temperature)
|
||||
(~docs/code :src (highlight "(defisland ~reactive-islands/demo/temperature ()\n (let ((celsius (signal 20)))\n (div :class \"...\"\n (button :on-click (fn (e) (swap! celsius (fn (c) (- c 5)))) \"−5\")\n (span (deref celsius))\n (button :on-click (fn (e) (swap! celsius (fn (c) (+ c 5)))) \"+5\")\n (span \"°C = \")\n (span (+ (* (deref celsius) 1.8) 32))\n (span \"°F\"))))" "lisp"))
|
||||
(p "The actual implementation uses " (code "computed") " for Fahrenheit: " (code "(computed (fn () (+ (* (deref celsius) 1.8) 32)))") ". The " (code "(deref fahrenheit)") " in the span creates a reactive text node that updates when celsius changes.")))
|
||||
(defcomp ~reactive-islands/demo/example-imperative () (~docs/page :title "Imperative Pattern" (p "For complex reactivity (dynamic classes, conditional text), use the imperative pattern: " (code "create-text-node") " + " (code "effect") " + " (code "dom-set-text-content") ".") (~reactive-islands/index/demo-imperative) (~docs/code :src (highlight "(defisland ~reactive-islands/demo/imperative ()\n (let ((count (signal 0))\n (text-node (create-text-node \"0\")))\n ;; Explicit effect: re-runs when count changes\n (effect (fn ()\n (dom-set-text-content text-node (str (deref count)))))\n (div :class \"...\"\n (span text-node)\n (button :on-click (fn (e) (swap! count inc)) \"+\"))))" "lisp")) (p "Two patterns exist: " (strong "declarative") " (" (code "(span (deref sig))") " — auto-reactive via " (code "reactive-text") ") and " (strong "imperative") " (" (code "create-text-node") " + " (code "effect") " — explicit, full control). Use declarative for simple text, imperative for dynamic classes, conditional DOM, or complex updates.")))
|
||||
|
||||
(defcomp ~reactive-islands/demo/example-stopwatch ()
|
||||
(~docs/page :title "Effect + Cleanup: Stopwatch"
|
||||
(p "Effects can return cleanup functions. This stopwatch starts a " (code "set-interval") " — the cleanup clears it when the running signal toggles off.")
|
||||
(~reactive-islands/index/demo-stopwatch)
|
||||
(~docs/code :src (highlight "(defisland ~reactive-islands/demo/stopwatch ()\n (let ((running (signal false))\n (elapsed (signal 0))\n (time-text (create-text-node \"0.0s\"))\n (btn-text (create-text-node \"Start\")))\n ;; Timer: effect creates interval, cleanup clears it\n (effect (fn ()\n (when (deref running)\n (let ((id (set-interval (fn () (swap! elapsed inc)) 100)))\n (fn () (clear-interval id))))))\n ;; Display: updates text node when elapsed changes\n (effect (fn ()\n (let ((e (deref elapsed)))\n (dom-set-text-content time-text\n (str (floor (/ e 10)) \".\" (mod e 10) \"s\")))))\n ;; Button label\n (effect (fn ()\n (dom-set-text-content btn-text\n (if (deref running) \"Stop\" \"Start\"))))\n (div :class \"...\"\n (span time-text)\n (button :on-click (fn (e) (swap! running not)) btn-text)\n (button :on-click (fn (e)\n (reset! running false) (reset! elapsed 0)) \"Reset\"))))" "lisp"))
|
||||
(p "Three effects, each tracking different signals. The timer effect's cleanup fires before each re-run — toggling " (code "running") " off clears the interval. No hook rules: effects can appear anywhere, in any order.")))
|
||||
(defcomp ~reactive-islands/demo/example-reactive-list () (~docs/page :title "Reactive List" (p "When " (code "map") " is used with " (code "(deref signal)") " inside an island, it auto-upgrades to a reactive list. With " (code ":key") " attributes, existing DOM nodes are reused across updates — only additions, removals, and reorderings touch the DOM.") (~reactive-islands/index/demo-reactive-list) (~docs/code :src (highlight "(defisland ~reactive-islands/demo/reactive-list ()\n (let ((next-id (signal 1))\n (items (signal (list)))\n (add-item (fn (e)\n (batch (fn ()\n (swap! items (fn (old)\n (append old (dict \"id\" (deref next-id)\n \"text\" (str \"Item \" (deref next-id))))))\n (swap! next-id inc)))))\n (remove-item (fn (id)\n (swap! items (fn (old)\n (filter (fn (item) (not (= (get item \"id\") id))) old))))))\n (div\n (button :on-click add-item \"Add Item\")\n (span (deref (computed (fn () (len (deref items))))) \" items\")\n (ul\n (map (fn (item)\n (li :key (str (get item \"id\"))\n (span (get item \"text\"))\n (button :on-click (fn (e) (remove-item (get item \"id\"))) \"✕\")))\n (deref items))))))" "lisp")) (p (code ":key") " identifies each list item. When items change, the reconciler matches old and new keys — reusing existing DOM nodes, inserting new ones, and removing stale ones. Without keys, the list falls back to clear-and-rerender. " (code "batch") " groups the two signal writes into one update pass.")))
|
||||
|
||||
(defcomp ~reactive-islands/demo/example-imperative ()
|
||||
(~docs/page :title "Imperative Pattern"
|
||||
(p "For complex reactivity (dynamic classes, conditional text), use the imperative pattern: " (code "create-text-node") " + " (code "effect") " + " (code "dom-set-text-content") ".")
|
||||
(~reactive-islands/index/demo-imperative)
|
||||
(~docs/code :src (highlight "(defisland ~reactive-islands/demo/imperative ()\n (let ((count (signal 0))\n (text-node (create-text-node \"0\")))\n ;; Explicit effect: re-runs when count changes\n (effect (fn ()\n (dom-set-text-content text-node (str (deref count)))))\n (div :class \"...\"\n (span text-node)\n (button :on-click (fn (e) (swap! count inc)) \"+\"))))" "lisp"))
|
||||
(p "Two patterns exist: " (strong "declarative") " (" (code "(span (deref sig))") " — auto-reactive via " (code "reactive-text") ") and " (strong "imperative") " (" (code "create-text-node") " + " (code "effect") " — explicit, full control). Use declarative for simple text, imperative for dynamic classes, conditional DOM, or complex updates.")))
|
||||
(defcomp ~reactive-islands/demo/example-input-binding () (~docs/page :title "Input Binding" (p "The " (code ":bind") " attribute creates a two-way link between a signal and a form element. Type in the input — the signal updates. Change the signal — the input updates. Works with text inputs, checkboxes, radios, textareas, and selects.") (~reactive-islands/index/demo-input-binding) (~docs/code :src (highlight "(defisland ~reactive-islands/demo/input-binding ()\n (let ((name (signal \"\"))\n (agreed (signal false)))\n (div\n (input :type \"text\" :bind name\n :placeholder \"Type your name...\")\n (span \"Hello, \" (strong (deref name)) \"!\")\n (input :type \"checkbox\" :bind agreed)\n (when (deref agreed)\n (p \"Thanks for agreeing!\")))))" "lisp")) (p (code ":bind") " detects the element type automatically — text inputs use " (code "value") " + " (code "input") " event, checkboxes use " (code "checked") " + " (code "change") " event. The effect only updates the DOM when the value actually changed, preventing cursor jump.")))
|
||||
|
||||
(defcomp ~reactive-islands/demo/example-reactive-list ()
|
||||
(~docs/page :title "Reactive List"
|
||||
(p "When " (code "map") " is used with " (code "(deref signal)") " inside an island, it auto-upgrades to a reactive list. With " (code ":key") " attributes, existing DOM nodes are reused across updates — only additions, removals, and reorderings touch the DOM.")
|
||||
(~reactive-islands/index/demo-reactive-list)
|
||||
(~docs/code :src (highlight "(defisland ~reactive-islands/demo/reactive-list ()\n (let ((next-id (signal 1))\n (items (signal (list)))\n (add-item (fn (e)\n (batch (fn ()\n (swap! items (fn (old)\n (append old (dict \"id\" (deref next-id)\n \"text\" (str \"Item \" (deref next-id))))))\n (swap! next-id inc)))))\n (remove-item (fn (id)\n (swap! items (fn (old)\n (filter (fn (item) (not (= (get item \"id\") id))) old))))))\n (div\n (button :on-click add-item \"Add Item\")\n (span (deref (computed (fn () (len (deref items))))) \" items\")\n (ul\n (map (fn (item)\n (li :key (str (get item \"id\"))\n (span (get item \"text\"))\n (button :on-click (fn (e) (remove-item (get item \"id\"))) \"✕\")))\n (deref items))))))" "lisp"))
|
||||
(p (code ":key") " identifies each list item. When items change, the reconciler matches old and new keys — reusing existing DOM nodes, inserting new ones, and removing stale ones. Without keys, the list falls back to clear-and-rerender. " (code "batch") " groups the two signal writes into one update pass.")))
|
||||
(defcomp ~reactive-islands/demo/example-portal () (~docs/page :title "Portals" (p "A " (code "portal") " renders children into a DOM node " (em "outside") " the island's subtree. Essential for modals, tooltips, and toasts — anything that must escape " (code "overflow:hidden") " or z-index stacking.") (~reactive-islands/index/demo-portal) (~docs/code :src (highlight "(defisland ~reactive-islands/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.")))
|
||||
|
||||
(defcomp ~reactive-islands/demo/example-input-binding ()
|
||||
(~docs/page :title "Input Binding"
|
||||
(p "The " (code ":bind") " attribute creates a two-way link between a signal and a form element. Type in the input — the signal updates. Change the signal — the input updates. Works with text inputs, checkboxes, radios, textareas, and selects.")
|
||||
(~reactive-islands/index/demo-input-binding)
|
||||
(~docs/code :src (highlight "(defisland ~reactive-islands/demo/input-binding ()\n (let ((name (signal \"\"))\n (agreed (signal false)))\n (div\n (input :type \"text\" :bind name\n :placeholder \"Type your name...\")\n (span \"Hello, \" (strong (deref name)) \"!\")\n (input :type \"checkbox\" :bind agreed)\n (when (deref agreed)\n (p \"Thanks for agreeing!\")))))" "lisp"))
|
||||
(p (code ":bind") " detects the element type automatically — text inputs use " (code "value") " + " (code "input") " event, checkboxes use " (code "checked") " + " (code "change") " event. The effect only updates the DOM when the value actually changed, preventing cursor jump.")))
|
||||
(defcomp ~reactive-islands/demo/example-error-boundary () (~docs/page :title "Error Boundaries" (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.") (~reactive-islands/index/demo-error-boundary) (~docs/code :src (highlight "(defisland ~reactive-islands/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") ".")))
|
||||
|
||||
(defcomp ~reactive-islands/demo/example-portal ()
|
||||
(~docs/page :title "Portals"
|
||||
(p "A " (code "portal") " renders children into a DOM node " (em "outside") " the island's subtree. Essential for modals, tooltips, and toasts — anything that must escape " (code "overflow:hidden") " or z-index stacking.")
|
||||
(~reactive-islands/index/demo-portal)
|
||||
(~docs/code :src (highlight "(defisland ~reactive-islands/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.")))
|
||||
(defcomp ~reactive-islands/demo/example-refs () (~docs/page :title "Refs — Imperative DOM Access" (p "The " (code ":ref") " attribute captures a DOM element handle into a dict. Use it for imperative operations: focusing, measuring, reading values.") (~reactive-islands/index/demo-refs) (~docs/code :src (highlight "(defisland ~reactive-islands/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\")") ".")))
|
||||
|
||||
(defcomp ~reactive-islands/demo/example-error-boundary ()
|
||||
(~docs/page :title "Error Boundaries"
|
||||
(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.")
|
||||
(~reactive-islands/index/demo-error-boundary)
|
||||
(~docs/code :src (highlight "(defisland ~reactive-islands/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") ".")))
|
||||
(defcomp ~reactive-islands/demo/example-dynamic-class () (~docs/page :title "Dynamic Class and Style" (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.") (~reactive-islands/index/demo-dynamic-class) (~docs/code :src (highlight "(defisland ~reactive-islands/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.")))
|
||||
|
||||
(defcomp ~reactive-islands/demo/example-refs ()
|
||||
(~docs/page :title "Refs — Imperative DOM Access"
|
||||
(p "The " (code ":ref") " attribute captures a DOM element handle into a dict. Use it for imperative operations: focusing, measuring, reading values.")
|
||||
(~reactive-islands/index/demo-refs)
|
||||
(~docs/code :src (highlight "(defisland ~reactive-islands/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\")") ".")))
|
||||
(defcomp ~reactive-islands/demo/example-resource () (~docs/page :title "Resource + Suspense Pattern" (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.") (~reactive-islands/index/demo-resource) (~docs/code :src (highlight "(defisland ~reactive-islands/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.")))
|
||||
|
||||
(defcomp ~reactive-islands/demo/example-dynamic-class ()
|
||||
(~docs/page :title "Dynamic Class and Style"
|
||||
(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.")
|
||||
(~reactive-islands/index/demo-dynamic-class)
|
||||
(~docs/code :src (highlight "(defisland ~reactive-islands/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.")))
|
||||
(defcomp ~reactive-islands/demo/example-transition () (~docs/page :title "Transition Pattern" (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.") (~reactive-islands/index/demo-transition) (~docs/code :src (highlight "(defisland ~reactive-islands/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.")))
|
||||
|
||||
(defcomp ~reactive-islands/demo/example-resource ()
|
||||
(~docs/page :title "Resource + Suspense Pattern"
|
||||
(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.")
|
||||
(~reactive-islands/index/demo-resource)
|
||||
(~docs/code :src (highlight "(defisland ~reactive-islands/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.")))
|
||||
(defcomp ~reactive-islands/demo/example-stores () (~docs/page :title "Shared 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.") (~reactive-islands/index/demo-store-writer) (~reactive-islands/index/demo-store-reader) (~docs/code :src (highlight ";; Island A — creates/writes the store\n(defisland ~reactive-islands/demo/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 ~reactive-islands/demo/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.")))
|
||||
|
||||
(defcomp ~reactive-islands/demo/example-transition ()
|
||||
(~docs/page :title "Transition Pattern"
|
||||
(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.")
|
||||
(~reactive-islands/index/demo-transition)
|
||||
(~docs/code :src (highlight "(defisland ~reactive-islands/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.")))
|
||||
(defcomp ~reactive-islands/demo/example-event-bridge-demo () (~docs/page :title "Event Bridge" (p "Server-rendered content inside an island (an htmx \"lake\") can communicate with island signals via DOM custom events. Buttons with " (code "data-sx-emit") " dispatch events that island effects catch.") (~reactive-islands/index/demo-event-bridge) (~docs/code :src (highlight ";; Island listens for custom events from server-rendered content\n(defisland ~reactive-islands/demo/event-bridge ()\n (let ((messages (signal (list))))\n ;; Bridge: auto-listen for \"inbox:message\" events\n (bridge-event container \"inbox:message\" messages\n (fn (detail) (append (deref messages) (get detail \"text\"))))\n (div\n ;; Lake content (server-rendered) has data-sx-emit buttons\n (div :id \"lake\"\n :sx-get \"/my-content\"\n :sx-swap \"innerHTML\"\n :sx-trigger \"load\")\n ;; Island reads the signal reactively\n (ul (map (fn (msg) (li msg)) (deref messages))))))" "lisp")) (p "The " (code "data-sx-emit") " attribute is processed by the client engine — it adds a click handler that dispatches a CustomEvent with the JSON from " (code "data-sx-emit-detail") ". The event bubbles up to the island container where " (code "bridge-event") " catches it.")))
|
||||
|
||||
(defcomp ~reactive-islands/demo/example-stores ()
|
||||
(~docs/page :title "Shared 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.")
|
||||
(~reactive-islands/index/demo-store-writer)
|
||||
(~reactive-islands/index/demo-store-reader)
|
||||
(~docs/code :src (highlight ";; Island A — creates/writes the store\n(defisland ~reactive-islands/demo/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 ~reactive-islands/demo/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.")))
|
||||
(defcomp ~reactive-islands/demo/example-defisland () (~docs/page :title "How defisland Works" (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.") (~docs/code :src (highlight ";; Definition — same syntax as defcomp\n(defisland ~reactive-islands/demo/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(~reactive-islands/demo/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.")))
|
||||
|
||||
(defcomp ~reactive-islands/demo/example-event-bridge-demo ()
|
||||
(~docs/page :title "Event Bridge"
|
||||
(p "Server-rendered content inside an island (an htmx \"lake\") can communicate with island signals via DOM custom events. Buttons with " (code "data-sx-emit") " dispatch events that island effects catch.")
|
||||
(~reactive-islands/index/demo-event-bridge)
|
||||
(~docs/code :src (highlight ";; Island listens for custom events from server-rendered content\n(defisland ~reactive-islands/demo/event-bridge ()\n (let ((messages (signal (list))))\n ;; Bridge: auto-listen for \"inbox:message\" events\n (bridge-event container \"inbox:message\" messages\n (fn (detail) (append (deref messages) (get detail \"text\"))))\n (div\n ;; Lake content (server-rendered) has data-sx-emit buttons\n (div :id \"lake\"\n :sx-get \"/my-content\"\n :sx-swap \"innerHTML\"\n :sx-trigger \"load\")\n ;; Island reads the signal reactively\n (ul (map (fn (msg) (li msg)) (deref messages))))))" "lisp"))
|
||||
(p "The " (code "data-sx-emit") " attribute is processed by the client engine — it adds a click handler that dispatches a CustomEvent with the JSON from " (code "data-sx-emit-detail") ". The event bubbles up to the island container where " (code "bridge-event") " catches it.")))
|
||||
(defcomp ~reactive-islands/demo/example-tests () (~docs/page :title "Test Suite" (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).") (~docs/code :src (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"))))
|
||||
|
||||
(defcomp ~reactive-islands/demo/example-defisland ()
|
||||
(~docs/page :title "How defisland Works"
|
||||
(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.")
|
||||
(~docs/code :src (highlight ";; Definition — same syntax as defcomp\n(defisland ~reactive-islands/demo/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(~reactive-islands/demo/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.")))
|
||||
|
||||
(defcomp ~reactive-islands/demo/example-tests ()
|
||||
(~docs/page :title "Test Suite"
|
||||
(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).")
|
||||
(~docs/code :src (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"))))
|
||||
|
||||
(defcomp ~reactive-islands/demo/example-coverage ()
|
||||
(~docs/page :title "React Feature 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" "")))))))
|
||||
(defcomp ~reactive-islands/demo/example-coverage () (~docs/page :title "React Feature 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" "")))))))
|
||||
|
||||
76
sx/sx/reactive-islands/test-runner.sx
Normal file
76
sx/sx/reactive-islands/test-runner.sx
Normal file
@@ -0,0 +1,76 @@
|
||||
;; ~reactive-islands/test-runner — inline test runner island
|
||||
;;
|
||||
;; Displays test results for a test suite. Runs tests on mount and
|
||||
;; shows pass/fail with details.
|
||||
|
||||
(defisland ~reactive-islands/test-runner (&key test-src)
|
||||
(let ((results (signal nil))
|
||||
(running (signal false)))
|
||||
|
||||
(letrec
|
||||
((run-tests (fn ()
|
||||
(reset! running true)
|
||||
(let ((parsed (sx-parse test-src))
|
||||
(test-results (list)))
|
||||
;; Walk parsed expressions looking for deftest/defsuite
|
||||
(for-each (fn (expr)
|
||||
(when (and (list? expr) (not (empty? expr))
|
||||
(= (type-of (first expr)) "symbol"))
|
||||
(let ((head (symbol-name (first expr))))
|
||||
(cond
|
||||
(= head "defsuite")
|
||||
;; Process each deftest in the suite
|
||||
(for-each (fn (child)
|
||||
(when (and (list? child) (not (empty? child))
|
||||
(= (type-of (first child)) "symbol")
|
||||
(= (symbol-name (first child)) "deftest"))
|
||||
(let ((test-name (nth child 1))
|
||||
(test-body (last child)))
|
||||
(let ((result (try-test test-name test-body)))
|
||||
(append! test-results result)))))
|
||||
(slice expr 2))
|
||||
(= head "deftest")
|
||||
(let ((test-name (nth expr 1))
|
||||
(test-body (last expr)))
|
||||
(append! test-results (try-test test-name test-body)))))))
|
||||
parsed)
|
||||
(reset! results test-results)
|
||||
(reset! running false))))
|
||||
|
||||
(try-test (fn (name body)
|
||||
(let ((error-msg nil))
|
||||
;; Evaluate the test body, catch assertion failures
|
||||
(let ((ok (cek-try
|
||||
(fn () (cek-eval (sx-serialize body)) true)
|
||||
(fn (err) (set! error-msg (str err)) false))))
|
||||
{:name name :pass ok :error error-msg})))))
|
||||
|
||||
;; Run on mount
|
||||
(run-tests)
|
||||
|
||||
(div :class "mt-6 rounded border border-stone-200 bg-stone-50 p-4"
|
||||
(div :class "flex items-center justify-between mb-3"
|
||||
(h4 :class "text-sm font-semibold text-stone-700" "Tests")
|
||||
(button :class "px-2 py-1 text-xs rounded bg-stone-200 hover:bg-stone-300"
|
||||
:on-click (fn (e) (run-tests))
|
||||
"Re-run"))
|
||||
|
||||
(if (deref running)
|
||||
(p :class "text-stone-400 text-sm italic" "Running...")
|
||||
(if (nil? (deref results))
|
||||
(p :class "text-stone-400 text-sm italic" "No results")
|
||||
(let ((r (deref results))
|
||||
(pass-count (len (filter (fn (t) (get t "pass")) r)))
|
||||
(fail-count (len (filter (fn (t) (not (get t "pass"))) r))))
|
||||
(div :class "space-y-2"
|
||||
(div :class "text-sm font-mono"
|
||||
(span :class (if (= fail-count 0) "text-emerald-600" "text-red-600")
|
||||
(str pass-count "/" (len r) " passed")))
|
||||
(map (fn (t)
|
||||
(div :class "flex items-start gap-2 text-xs font-mono py-0.5"
|
||||
(span :class (if (get t "pass") "text-emerald-500" "text-red-500")
|
||||
(if (get t "pass") "✓" "✗"))
|
||||
(span :class "text-stone-600" (get t "name"))
|
||||
(when (get t "error")
|
||||
(span :class "text-red-400 ml-2" (get t "error")))))
|
||||
r)))))))))
|
||||
76
sx/sx/reactive-islands/test-temperature.sx
Normal file
76
sx/sx/reactive-islands/test-temperature.sx
Normal file
@@ -0,0 +1,76 @@
|
||||
;; Tests for ~reactive-islands/index/demo-temperature
|
||||
;;
|
||||
;; Tests the reactive logic: signal creation, computed derivation,
|
||||
;; button click handlers, and value propagation.
|
||||
|
||||
(defsuite "temperature converter"
|
||||
|
||||
(deftest "initial celsius is 20"
|
||||
(let ((celsius (signal 20)))
|
||||
(assert-signal-value celsius 20)))
|
||||
|
||||
(deftest "computed fahrenheit derives from celsius"
|
||||
(let ((celsius (signal 20))
|
||||
(fahrenheit (computed (fn () (+ (* (deref celsius) 1.8) 32)))))
|
||||
(assert-signal-value fahrenheit 68)
|
||||
(assert-computed-depends-on fahrenheit celsius)))
|
||||
|
||||
(deftest "+5 button increments celsius by 5"
|
||||
(let ((celsius (signal 20))
|
||||
(btn (mock-element "button")))
|
||||
(mock-add-listener! btn "click"
|
||||
(fn (e) (swap! celsius (fn (c) (+ c 5)))))
|
||||
(simulate-click btn)
|
||||
(assert-signal-value celsius 25)))
|
||||
|
||||
(deftest "−5 button decrements celsius by 5"
|
||||
(let ((celsius (signal 20))
|
||||
(btn (mock-element "button")))
|
||||
(mock-add-listener! btn "click"
|
||||
(fn (e) (swap! celsius (fn (c) (- c 5)))))
|
||||
(simulate-click btn)
|
||||
(assert-signal-value celsius 15)))
|
||||
|
||||
(deftest "fahrenheit updates when celsius changes"
|
||||
(let ((celsius (signal 20))
|
||||
(fahrenheit (computed (fn () (+ (* (deref celsius) 1.8) 32)))))
|
||||
(assert-signal-value fahrenheit 68)
|
||||
(reset! celsius 25)
|
||||
(assert-signal-value fahrenheit 77)
|
||||
(reset! celsius 0)
|
||||
(assert-signal-value fahrenheit 32)
|
||||
(reset! celsius 100)
|
||||
(assert-signal-value fahrenheit 212)))
|
||||
|
||||
(deftest "multiple clicks accumulate"
|
||||
(let ((celsius (signal 20))
|
||||
(fahrenheit (computed (fn () (+ (* (deref celsius) 1.8) 32))))
|
||||
(plus-btn (mock-element "button"))
|
||||
(minus-btn (mock-element "button")))
|
||||
(mock-add-listener! plus-btn "click"
|
||||
(fn (e) (swap! celsius (fn (c) (+ c 5)))))
|
||||
(mock-add-listener! minus-btn "click"
|
||||
(fn (e) (swap! celsius (fn (c) (- c 5)))))
|
||||
;; +5 +5 +5
|
||||
(simulate-click plus-btn)
|
||||
(simulate-click plus-btn)
|
||||
(simulate-click plus-btn)
|
||||
(assert-signal-value celsius 35)
|
||||
(assert-signal-value fahrenheit 95)
|
||||
;; -5
|
||||
(simulate-click minus-btn)
|
||||
(assert-signal-value celsius 30)
|
||||
(assert-signal-value fahrenheit 86)))
|
||||
|
||||
(deftest "celsius signal has subscribers after computed"
|
||||
(let ((celsius (signal 20))
|
||||
(fahrenheit (computed (fn () (+ (* (deref celsius) 1.8) 32)))))
|
||||
(assert-signal-has-subscribers celsius)))
|
||||
|
||||
(deftest "click events are logged on mock element"
|
||||
(let ((btn (mock-element "button")))
|
||||
(mock-add-listener! btn "click" (fn (e) nil))
|
||||
(simulate-click btn)
|
||||
(simulate-click btn)
|
||||
(assert-event-fired btn "click")
|
||||
(assert= (event-fire-count btn "click") 2 "Expected 2 click events"))))
|
||||
Reference in New Issue
Block a user