Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 1m5s
- web/orchestration.sx, web/signals.sx: dom-listen → dom-on (trampoline wrapper that resolves TCO thunks from Lambda event handlers) - .gitea/: CI workflow and Dockerfile for automated test runs - tests/playwright/stepper.spec.js: stepper widget smoke test - Remove stale artdag .pyc file Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
480 lines
19 KiB
Plaintext
480 lines
19 KiB
Plaintext
;; ==========================================================================
|
|
;; signals.sx — Reactive signal runtime specification
|
|
;;
|
|
;; Defines the signal primitive: a container for a value that notifies
|
|
;; subscribers when it changes. Signals are the reactive state primitive
|
|
;; for SX islands.
|
|
;;
|
|
;; Signals are pure computation — no DOM, no IO. The reactive rendering
|
|
;; layer (adapter-dom.sx) subscribes DOM nodes to signals. The server
|
|
;; adapter (adapter-html.sx) reads signal values without subscribing.
|
|
;;
|
|
;; Signals are plain dicts with a "__signal" marker key. No platform
|
|
;; primitives needed — all signal operations are pure SX.
|
|
;;
|
|
;; Reactive tracking and island lifecycle use the general scoped effects
|
|
;; system (scope-push!/scope-pop!/context) instead of separate globals.
|
|
;; Two scope names:
|
|
;; "sx-reactive" — tracking context for computed/effect dep discovery
|
|
;; "sx-island-scope" — island disposable collector
|
|
;;
|
|
;; Scope-based tracking:
|
|
;; (scope-push! "sx-reactive" {:deps (list) :notify fn}) → void
|
|
;; (scope-pop! "sx-reactive") → void
|
|
;; (context "sx-reactive" nil) → dict or nil
|
|
;;
|
|
;; CEK callable dispatch:
|
|
;; (cek-call f args) → any — call f with args list via CEK.
|
|
;; Dispatches through cek-run for SX
|
|
;; lambdas, apply for native callables.
|
|
;; Defined in cek.sx.
|
|
;;
|
|
;; ==========================================================================
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; Signal container — plain dict with marker key
|
|
;; --------------------------------------------------------------------------
|
|
;;
|
|
;; A signal is a dict: {"__signal" true, "value" v, "subscribers" [], "deps" []}
|
|
;; type-of returns "dict". Use signal? to distinguish from regular dicts.
|
|
|
|
(define make-signal (fn (value)
|
|
(dict "__signal" true "value" value "subscribers" (list) "deps" (list))))
|
|
|
|
(define signal? (fn (x)
|
|
(and (dict? x) (has-key? x "__signal"))))
|
|
|
|
(define signal-value (fn (s) (get s "value")))
|
|
(define signal-set-value! (fn (s v) (dict-set! s "value" v)))
|
|
(define signal-subscribers (fn (s) (get s "subscribers")))
|
|
|
|
(define signal-add-sub! (fn (s f)
|
|
(when (not (contains? (get s "subscribers") f))
|
|
(append! (get s "subscribers") f))))
|
|
|
|
(define signal-remove-sub! (fn (s f)
|
|
(dict-set! s "subscribers"
|
|
(filter (fn (sub) (not (identical? sub f)))
|
|
(get s "subscribers")))))
|
|
|
|
(define signal-deps (fn (s) (get s "deps")))
|
|
(define signal-set-deps! (fn (s deps) (dict-set! s "deps" deps)))
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; 1. signal — create a reactive container
|
|
;; --------------------------------------------------------------------------
|
|
|
|
(define signal :effects []
|
|
(fn ((initial-value :as any))
|
|
(make-signal initial-value)))
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; 2. deref — read signal value, subscribe current reactive context
|
|
;; --------------------------------------------------------------------------
|
|
;;
|
|
;; In a reactive context (inside effect or computed), deref registers the
|
|
;; signal as a dependency. Outside reactive context, deref just returns
|
|
;; the current value — no subscription, no overhead.
|
|
|
|
(define deref :effects []
|
|
(fn ((s :as any))
|
|
(if (not (signal? s))
|
|
s ;; non-signal values pass through
|
|
(let ((ctx (context "sx-reactive" nil)))
|
|
(when ctx
|
|
;; Register this signal as a dependency of the current context
|
|
(let ((dep-list (get ctx "deps"))
|
|
(notify-fn (get ctx "notify")))
|
|
(when (not (contains? dep-list s))
|
|
(append! dep-list s)
|
|
(signal-add-sub! s notify-fn))))
|
|
(signal-value s)))))
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; 3. reset! — write a new value, notify subscribers
|
|
;; --------------------------------------------------------------------------
|
|
|
|
(define reset! :effects [mutation]
|
|
(fn ((s :as signal) value)
|
|
(when (signal? s)
|
|
(let ((old (signal-value s)))
|
|
(when (not (identical? old value))
|
|
(signal-set-value! s value)
|
|
(notify-subscribers s))))))
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; 4. swap! — update signal via function
|
|
;; --------------------------------------------------------------------------
|
|
|
|
(define swap! :effects [mutation]
|
|
(fn ((s :as signal) (f :as lambda) &rest args)
|
|
(when (signal? s)
|
|
(let ((old (signal-value s))
|
|
(new-val (apply f (cons old args))))
|
|
(when (not (identical? old new-val))
|
|
(signal-set-value! s new-val)
|
|
(notify-subscribers s))))))
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; 5. computed — derived signal with automatic dependency tracking
|
|
;; --------------------------------------------------------------------------
|
|
;;
|
|
;; A computed signal wraps a zero-arg function. It re-evaluates when any
|
|
;; of its dependencies change. The dependency set is discovered automatically
|
|
;; by tracking deref calls during evaluation.
|
|
|
|
(define computed :effects [mutation]
|
|
(fn ((compute-fn :as lambda))
|
|
(let ((s (make-signal nil))
|
|
(deps (list))
|
|
(compute-ctx nil))
|
|
|
|
;; The notify function — called when a dependency changes
|
|
(let ((recompute
|
|
(fn ()
|
|
;; Unsubscribe from old deps
|
|
(for-each
|
|
(fn ((dep :as signal)) (signal-remove-sub! dep recompute))
|
|
(signal-deps s))
|
|
(signal-set-deps! s (list))
|
|
|
|
;; Push scope-based tracking context for this computed
|
|
(let ((ctx (dict "deps" (list) "notify" recompute)))
|
|
(scope-push! "sx-reactive" ctx)
|
|
(let ((new-val (cek-call compute-fn nil)))
|
|
(scope-pop! "sx-reactive")
|
|
;; Save discovered deps
|
|
(signal-set-deps! s (get ctx "deps"))
|
|
;; Update value + notify downstream
|
|
(let ((old (signal-value s)))
|
|
(signal-set-value! s new-val)
|
|
(when (not (identical? old new-val))
|
|
(notify-subscribers s))))))))
|
|
|
|
;; Initial computation
|
|
(recompute)
|
|
;; Auto-register disposal with island scope
|
|
(register-in-scope (fn () (dispose-computed s)))
|
|
s))))
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; 6. effect — side effect that runs when dependencies change
|
|
;; --------------------------------------------------------------------------
|
|
;;
|
|
;; Like computed, but doesn't produce a signal value. Returns a dispose
|
|
;; function that tears down the effect.
|
|
|
|
(define effect :effects [mutation]
|
|
(fn ((effect-fn :as lambda))
|
|
(let ((deps (list))
|
|
(disposed false)
|
|
(cleanup-fn nil))
|
|
|
|
(let ((run-effect
|
|
(fn ()
|
|
(when (not disposed)
|
|
;; Run previous cleanup if any
|
|
(when cleanup-fn (cek-call cleanup-fn nil))
|
|
|
|
;; Unsubscribe from old deps
|
|
(for-each
|
|
(fn ((dep :as signal)) (signal-remove-sub! dep run-effect))
|
|
deps)
|
|
(set! deps (list))
|
|
|
|
;; Push scope-based tracking context
|
|
(let ((ctx (dict "deps" (list) "notify" run-effect)))
|
|
(scope-push! "sx-reactive" ctx)
|
|
(let ((result (cek-call effect-fn nil)))
|
|
(scope-pop! "sx-reactive")
|
|
(set! deps (get ctx "deps"))
|
|
;; If effect returns a function, it's the cleanup
|
|
(when (callable? result)
|
|
(set! cleanup-fn result))))))))
|
|
|
|
;; Initial run
|
|
(run-effect)
|
|
|
|
;; Return dispose function
|
|
(let ((dispose-fn
|
|
(fn ()
|
|
(set! disposed true)
|
|
(when cleanup-fn (cek-call cleanup-fn nil))
|
|
(for-each
|
|
(fn ((dep :as signal)) (signal-remove-sub! dep run-effect))
|
|
deps)
|
|
(set! deps (list)))))
|
|
;; Auto-register with island scope so disposal happens on swap
|
|
(register-in-scope dispose-fn)
|
|
dispose-fn)))))
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; 7. batch — group multiple signal writes into one notification pass
|
|
;; --------------------------------------------------------------------------
|
|
;;
|
|
;; During a batch, signal writes are deferred. Subscribers are notified
|
|
;; once at the end, after all values have been updated.
|
|
|
|
(define *batch-depth* 0)
|
|
(define *batch-queue* (list))
|
|
|
|
(define batch :effects [mutation]
|
|
(fn ((thunk :as lambda))
|
|
(set! *batch-depth* (+ *batch-depth* 1))
|
|
(cek-call thunk nil)
|
|
(set! *batch-depth* (- *batch-depth* 1))
|
|
(when (= *batch-depth* 0)
|
|
(let ((queue *batch-queue*))
|
|
(set! *batch-queue* (list))
|
|
;; Collect unique subscribers across all queued signals,
|
|
;; then notify each exactly once.
|
|
(let ((seen (list))
|
|
(pending (list)))
|
|
(for-each
|
|
(fn ((s :as signal))
|
|
(for-each
|
|
(fn ((sub :as lambda))
|
|
(when (not (contains? seen sub))
|
|
(append! seen sub)
|
|
(append! pending sub)))
|
|
(signal-subscribers s)))
|
|
queue)
|
|
(for-each (fn ((sub :as lambda)) (sub)) pending))))))
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; 8. notify-subscribers — internal notification dispatch
|
|
;; --------------------------------------------------------------------------
|
|
;;
|
|
;; If inside a batch, queues the signal. Otherwise, notifies immediately.
|
|
|
|
(define notify-subscribers :effects [mutation]
|
|
(fn ((s :as signal))
|
|
(if (> *batch-depth* 0)
|
|
(when (not (contains? *batch-queue* s))
|
|
(append! *batch-queue* s))
|
|
(flush-subscribers s))))
|
|
|
|
(define flush-subscribers :effects [mutation]
|
|
(fn ((s :as signal))
|
|
(for-each
|
|
(fn ((sub :as lambda)) (sub))
|
|
(signal-subscribers s))))
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; 9. Reactive tracking context
|
|
;; --------------------------------------------------------------------------
|
|
;;
|
|
;; Tracking is now scope-based. computed/effect push a dict
|
|
;; {:deps (list) :notify fn} onto the "sx-reactive" scope stack via
|
|
;; scope-push!/scope-pop!. deref reads it via (context "sx-reactive" nil).
|
|
;; No platform primitives needed — uses the existing scope infrastructure.
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; 10. dispose — tear down a computed signal
|
|
;; --------------------------------------------------------------------------
|
|
;;
|
|
;; For computed signals, unsubscribe from all dependencies.
|
|
;; For effects, the dispose function is returned by effect itself.
|
|
|
|
(define dispose-computed :effects [mutation]
|
|
(fn ((s :as signal))
|
|
(when (signal? s)
|
|
(for-each
|
|
(fn ((dep :as signal)) (signal-remove-sub! dep nil))
|
|
(signal-deps s))
|
|
(signal-set-deps! s (list)))))
|
|
|
|
|
|
;; --------------------------------------------------------------------------
|
|
;; 11. Island scope — automatic cleanup of signals within an island
|
|
;; --------------------------------------------------------------------------
|
|
;;
|
|
;; When an island is created, all signals, effects, and computeds created
|
|
;; within it are tracked. When the island is removed from the DOM, they
|
|
;; are all disposed.
|
|
;;
|
|
;; Uses "sx-island-scope" scope name. The scope value is a collector
|
|
;; function (fn (disposable) ...) that appends to the island's disposer list.
|
|
|
|
(define with-island-scope :effects [mutation]
|
|
(fn ((scope-fn :as lambda) (body-fn :as lambda))
|
|
(scope-push! "sx-island-scope" scope-fn)
|
|
(let ((result (body-fn)))
|
|
(scope-pop! "sx-island-scope")
|
|
result)))
|
|
|
|
;; Hook into signal/effect/computed creation for scope tracking.
|
|
|
|
(define register-in-scope :effects [mutation]
|
|
(fn ((disposable :as lambda))
|
|
(let ((collector (context "sx-island-scope" nil)))
|
|
(when collector
|
|
(cek-call collector (list disposable))))))
|
|
|
|
|
|
;; ==========================================================================
|
|
;; 12. Marsh scopes — child scopes within islands
|
|
;; ==========================================================================
|
|
;;
|
|
;; Marshes are zones inside islands where server content is re-evaluated
|
|
;; in the island's reactive context. When a marsh is re-morphed with new
|
|
;; content, its old effects and computeds must be disposed WITHOUT disturbing
|
|
;; the island's own reactive graph.
|
|
;;
|
|
;; Scope hierarchy: island → marsh → effects/computeds
|
|
;; Disposing a marsh disposes its subscope. Disposing an island disposes
|
|
;; all its marshes. The signal graph is a tree, not a flat list.
|
|
;;
|
|
;; Platform interface required:
|
|
;; (dom-set-data el key val) → void — store JS value on element
|
|
;; (dom-get-data el key) → any — retrieve stored value
|
|
|
|
(define with-marsh-scope :effects [mutation io]
|
|
(fn (marsh-el (body-fn :as lambda))
|
|
;; Execute body-fn collecting all disposables into a marsh-local list.
|
|
;; Nested under the current island scope — if the island is disposed,
|
|
;; the marsh is disposed too (because island scope collected the marsh's
|
|
;; own dispose function).
|
|
(let ((disposers (list)))
|
|
(with-island-scope
|
|
(fn (d) (append! disposers d))
|
|
body-fn)
|
|
;; Store disposers on the marsh element for later cleanup
|
|
(dom-set-data marsh-el "sx-marsh-disposers" disposers))))
|
|
|
|
(define dispose-marsh-scope :effects [mutation io]
|
|
(fn (marsh-el)
|
|
;; Dispose all effects/computeds registered in this marsh's scope.
|
|
;; Parent island scope and sibling marshes are unaffected.
|
|
(let ((disposers (dom-get-data marsh-el "sx-marsh-disposers")))
|
|
(when disposers
|
|
(for-each (fn ((d :as lambda)) (cek-call d nil)) disposers)
|
|
(dom-set-data marsh-el "sx-marsh-disposers" nil)))))
|
|
|
|
|
|
;; ==========================================================================
|
|
;; 13. Named stores — page-level signal containers (L3)
|
|
;; ==========================================================================
|
|
;;
|
|
;; Stores persist across island creation/destruction. They live at page
|
|
;; scope, not island scope. When an island is swapped out and re-created,
|
|
;; it reconnects to the same store instance.
|
|
;;
|
|
;; The store registry is global page-level state. It survives island
|
|
;; disposal but is cleared on full page navigation.
|
|
|
|
(define *store-registry* (dict))
|
|
|
|
(define def-store :effects [mutation]
|
|
(fn ((name :as string) (init-fn :as lambda))
|
|
(let ((registry *store-registry*))
|
|
;; Only create the store once — subsequent calls return existing
|
|
(when (not (has-key? registry name))
|
|
(set! *store-registry* (assoc registry name (cek-call init-fn nil))))
|
|
(get *store-registry* name))))
|
|
|
|
(define use-store :effects []
|
|
(fn ((name :as string))
|
|
(if (has-key? *store-registry* name)
|
|
(get *store-registry* name)
|
|
(error (str "Store not found: " name
|
|
". Call (def-store ...) before (use-store ...).")))))
|
|
|
|
(define clear-stores :effects [mutation]
|
|
(fn ()
|
|
(set! *store-registry* (dict))))
|
|
|
|
|
|
;; ==========================================================================
|
|
;; 13. Event bridge — DOM event communication for lake→island
|
|
;; ==========================================================================
|
|
;;
|
|
;; Server-rendered content ("htmx lakes") inside reactive islands can
|
|
;; communicate with island signals via DOM custom events. The bridge
|
|
;; pattern:
|
|
;;
|
|
;; 1. Server renders a button/link with data-sx-emit="event-name"
|
|
;; 2. When clicked, the client dispatches a CustomEvent on the element
|
|
;; 3. The event bubbles up to the island container
|
|
;; 4. An island effect listens for the event and updates signals
|
|
;;
|
|
;; This keeps server content pure HTML — no signal references needed.
|
|
;; The island effect is the only reactive code.
|
|
;;
|
|
;; Platform interface required:
|
|
;; (dom-listen el event-name handler) → remove-fn
|
|
;; (dom-dispatch el event-name detail) → void
|
|
;; (event-detail e) → any
|
|
;;
|
|
;; These are platform primitives because they require browser DOM APIs.
|
|
|
|
(define emit-event :effects [io]
|
|
(fn (el (event-name :as string) detail)
|
|
(dom-dispatch el event-name detail)))
|
|
|
|
(define on-event :effects [io]
|
|
(fn (el (event-name :as string) (handler :as lambda))
|
|
(dom-on el event-name handler)))
|
|
|
|
;; Convenience: create an effect that listens for a DOM event on an
|
|
;; element and writes the event detail (or a transformed value) into
|
|
;; a target signal. Returns the effect's dispose function.
|
|
;; When the effect is disposed (island teardown), the listener is
|
|
;; removed automatically via the cleanup return.
|
|
|
|
(define bridge-event :effects [mutation io]
|
|
(fn (el (event-name :as string) (target-signal :as signal) transform-fn)
|
|
(effect (fn ()
|
|
(let ((remove (dom-on el event-name
|
|
(fn (e)
|
|
(let ((detail (event-detail e))
|
|
(new-val (if transform-fn
|
|
(cek-call transform-fn (list detail))
|
|
detail)))
|
|
(reset! target-signal new-val))))))
|
|
;; Return cleanup — removes listener on dispose/re-run
|
|
remove)))))
|
|
|
|
|
|
;; ==========================================================================
|
|
;; 14. Resource — async signal with loading/resolved/error states
|
|
;; ==========================================================================
|
|
;;
|
|
;; A resource wraps an async operation (fetch, computation) and exposes
|
|
;; its state as a signal. The signal transitions through:
|
|
;; {:loading true :data nil :error nil} — initial/loading
|
|
;; {:loading false :data result :error nil} — success
|
|
;; {:loading false :data nil :error err} — failure
|
|
;;
|
|
;; Usage:
|
|
;; (let ((user (resource (fn () (fetch-json "/api/user")))))
|
|
;; (cond
|
|
;; (get (deref user) "loading") (div "Loading...")
|
|
;; (get (deref user) "error") (div "Error: " (get (deref user) "error"))
|
|
;; :else (div (get (deref user) "data"))))
|
|
;;
|
|
;; Platform interface required:
|
|
;; (promise-then promise on-resolve on-reject) → void
|
|
|
|
(define resource :effects [mutation io]
|
|
(fn ((fetch-fn :as lambda))
|
|
(let ((state (signal (dict "loading" true "data" nil "error" nil))))
|
|
;; Kick off the async operation
|
|
(promise-then (cek-call fetch-fn nil)
|
|
(fn (data) (reset! state (dict "loading" false "data" data "error" nil)))
|
|
(fn (err) (reset! state (dict "loading" false "data" nil "error" err))))
|
|
state)))
|
|
|
|
|