;; ========================================================================== ;; spec/signals.sx — Core reactive signal specification ;; ;; Defines the signal primitive: a container for a value that notifies ;; subscribers when it changes. Signals are the core reactive state ;; primitive for SX — usable in any context (web, CLI, embedded, server). ;; ;; Signals are pure computation — no DOM, no IO. Platform-specific ;; extensions (island scopes, DOM rendering, events) live in web/signals.sx. ;; ;; Signals are plain dicts with a "__signal" marker key. No platform ;; primitives needed — all signal operations are pure SX. ;; ;; Reactive tracking uses the general scope system: ;; "sx-reactive" — tracking context for computed/effect dep discovery ;; "sx-island-scope" — disposable collector (named for history, works anywhere) ;; ;; 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 machine. ;; ========================================================================== ;; -------------------------------------------------------------------------- ;; Signal container — plain dict with marker key ;; -------------------------------------------------------------------------- (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))) ;; -------------------------------------------------------------------------- ;; signal — create a reactive container ;; -------------------------------------------------------------------------- (define signal :effects [] (fn ((initial-value :as any)) (make-signal initial-value))) ;; -------------------------------------------------------------------------- ;; deref — read signal value, subscribe current reactive context ;; -------------------------------------------------------------------------- (define deref :effects [] (fn ((s :as any)) (if (not (signal? s)) s (let ((ctx (context "sx-reactive" nil))) (when ctx (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))))) ;; -------------------------------------------------------------------------- ;; 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)))))) ;; -------------------------------------------------------------------------- ;; 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)))))) ;; -------------------------------------------------------------------------- ;; computed — derived signal with automatic dependency tracking ;; -------------------------------------------------------------------------- (define computed :effects [mutation] (fn ((compute-fn :as lambda)) (let ((s (make-signal nil)) (deps (list)) (compute-ctx nil)) (let ((recompute (fn () (for-each (fn ((dep :as signal)) (signal-remove-sub! dep recompute)) (signal-deps s)) (signal-set-deps! s (list)) (let ((ctx (dict "deps" (list) "notify" recompute))) (scope-push! "sx-reactive" ctx) (let ((new-val (cek-call compute-fn nil))) (scope-pop! "sx-reactive") (signal-set-deps! s (get ctx "deps")) (let ((old (signal-value s))) (signal-set-value! s new-val) (when (not (identical? old new-val)) (notify-subscribers s)))))))) (recompute) (register-in-scope (fn () (dispose-computed s))) s)))) ;; -------------------------------------------------------------------------- ;; effect — side effect that runs when dependencies change ;; -------------------------------------------------------------------------- (define effect :effects [mutation] (fn ((effect-fn :as lambda)) (let ((deps (list)) (disposed false) (cleanup-fn nil)) (let ((run-effect (fn () (when (not disposed) (when cleanup-fn (cek-call cleanup-fn nil)) (for-each (fn ((dep :as signal)) (signal-remove-sub! dep run-effect)) deps) (set! deps (list)) (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")) (when (callable? result) (set! cleanup-fn result)))))))) (run-effect) (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))))) (register-in-scope dispose-fn) dispose-fn))))) ;; -------------------------------------------------------------------------- ;; batch — group multiple signal writes into one notification pass ;; -------------------------------------------------------------------------- (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)) (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)))))) ;; -------------------------------------------------------------------------- ;; notify-subscribers — internal notification dispatch ;; -------------------------------------------------------------------------- (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)))) ;; -------------------------------------------------------------------------- ;; dispose-computed — tear down a computed signal ;; -------------------------------------------------------------------------- (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))))) ;; -------------------------------------------------------------------------- ;; Reactive scope — automatic cleanup of signals within a scope ;; -------------------------------------------------------------------------- (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))) (define register-in-scope :effects [mutation] (fn ((disposable :as lambda)) (let ((collector (scope-peek "sx-island-scope"))) (when collector (cek-call collector (list disposable))))))