;; ========================================================================== ;; 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. ;; ;; Platform interface required: ;; (make-signal value) → Signal — create signal container ;; (signal? x) → boolean — type predicate ;; (signal-value s) → any — read current value (no tracking) ;; (signal-set-value! s v) → void — write value (no notification) ;; (signal-subscribers s) → list — list of subscriber fns ;; (signal-add-sub! s fn) → void — add subscriber ;; (signal-remove-sub! s fn) → void — remove subscriber ;; (signal-deps s) → list — dependency list (for computed) ;; (signal-set-deps! s deps) → void — set dependency list ;; ;; Global state required: ;; *tracking-context* → nil | Effect/Computed currently evaluating ;; (set-tracking-context! c) → void ;; (get-tracking-context) → context or nil ;; ;; Runtime callable dispatch: ;; (invoke f &rest args) → any — call f with args; handles both ;; native host functions AND SX lambdas ;; from runtime-evaluated code (islands). ;; Transpiled code emits direct calls ;; f(args) which fail on SX lambdas. ;; invoke goes through the evaluator's ;; dispatch (call-fn) so either works. ;; ;; ========================================================================== ;; -------------------------------------------------------------------------- ;; 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 (get-tracking-context))) (when ctx ;; Register this signal as a dependency of the current context (tracking-context-add-dep! ctx s) ;; Subscribe the context to this signal (signal-add-sub! s (tracking-context-notify-fn ctx))) (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)) ;; Create tracking context for this computed (let ((ctx (make-tracking-context recompute))) (let ((prev (get-tracking-context))) (set-tracking-context! ctx) (let ((new-val (invoke compute-fn))) (set-tracking-context! prev) ;; Save discovered deps (signal-set-deps! s (tracking-context-deps ctx)) ;; 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 (invoke cleanup-fn)) ;; Unsubscribe from old deps (for-each (fn ((dep :as signal)) (signal-remove-sub! dep run-effect)) deps) (set! deps (list)) ;; Track new deps (let ((ctx (make-tracking-context run-effect))) (let ((prev (get-tracking-context))) (set-tracking-context! ctx) (let ((result (invoke effect-fn))) (set-tracking-context! prev) (set! deps (tracking-context-deps ctx)) ;; 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 (invoke cleanup-fn)) (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)) (invoke thunk) (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. Tracking context ;; -------------------------------------------------------------------------- ;; ;; A tracking context is an ephemeral object created during effect/computed ;; evaluation to discover signal dependencies. Platform must provide: ;; ;; (make-tracking-context notify-fn) → context ;; (tracking-context-deps ctx) → list of signals ;; (tracking-context-add-dep! ctx s) → void (adds s to ctx's dep list) ;; (tracking-context-notify-fn ctx) → the notify function ;; ;; These are platform primitives because the context is mutable state ;; that must be efficient (often a Set in the host language). ;; -------------------------------------------------------------------------- ;; 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. (define *island-scope* nil) (define with-island-scope :effects [mutation] (fn ((scope-fn :as lambda) (body-fn :as lambda)) (let ((prev *island-scope*)) (set! *island-scope* scope-fn) (let ((result (body-fn))) (set! *island-scope* prev) result)))) ;; Hook into signal/effect/computed creation for scope tracking. ;; The platform's make-signal should call (register-in-scope s) if ;; *island-scope* is non-nil. (define register-in-scope :effects [mutation] (fn ((disposable :as lambda)) (when *island-scope* (*island-scope* 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)) (invoke d)) 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 (invoke init-fn)))) (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-listen 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-listen el event-name (fn (e) (let ((detail (event-detail e)) (new-val (if transform-fn (invoke transform-fn 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 (invoke fetch-fn) (fn (data) (reset! state (dict "loading" false "data" data "error" nil))) (fn (err) (reset! state (dict "loading" false "data" nil "error" err)))) state)))