Files
rose-ash/shared/sx/ref/signals.sx
giles 26320abd64 Add signal test suite (17/17) and Island type to evaluator
test-signals.sx: 17 tests covering signal basics (create, deref, reset!,
swap!), computed (derive, update, chain), effects (run, re-run, dispose,
cleanup), batch (deferred deduped notifications), and defisland (create,
call, children).

types.py: Island dataclass mirroring Component but for reactive boundaries.
evaluator.py: sf_defisland special form, Island in call dispatch.
run.py: Signal platform primitives (make-signal, tracking context, etc)
  and native effect/computed/batch implementations that bridge Lambda
  calls across the Python↔SX boundary.
signals.sx: Updated batch to deduplicate subscribers across signals.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 09:44:18 +00:00

291 lines
10 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.
;;
;; 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
;;
;; ==========================================================================
;; --------------------------------------------------------------------------
;; 1. signal — create a reactive container
;; --------------------------------------------------------------------------
(define signal
(fn (initial-value)
(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
(fn (s)
(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!
(fn (s 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!
(fn (s f &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
(fn (compute-fn)
(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) (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 (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)
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
(fn (effect-fn)
(let ((deps (list))
(disposed false)
(cleanup-fn nil))
(let ((run-effect
(fn ()
(when (not disposed)
;; Run previous cleanup if any
(when cleanup-fn (cleanup-fn))
;; Unsubscribe from old deps
(for-each
(fn (dep) (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 (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
(fn ()
(set! disposed true)
(when cleanup-fn (cleanup-fn))
(for-each
(fn (dep) (signal-remove-sub! dep run-effect))
deps)
(set! deps (list)))))))
;; --------------------------------------------------------------------------
;; 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
(fn (thunk)
(set! *batch-depth* (+ *batch-depth* 1))
(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)
(for-each
(fn (sub)
(when (not (contains? seen sub))
(append! seen sub)
(append! pending sub)))
(signal-subscribers s)))
queue)
(for-each (fn (sub) (sub)) pending))))))
;; --------------------------------------------------------------------------
;; 8. notify-subscribers — internal notification dispatch
;; --------------------------------------------------------------------------
;;
;; If inside a batch, queues the signal. Otherwise, notifies immediately.
(define notify-subscribers
(fn (s)
(if (> *batch-depth* 0)
(when (not (contains? *batch-queue* s))
(append! *batch-queue* s))
(flush-subscribers s))))
(define flush-subscribers
(fn (s)
(for-each
(fn (sub) (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
(fn (s)
(when (signal? s)
(for-each
(fn (dep) (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
(fn (scope-fn body-fn)
(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
(fn (disposable)
(when *island-scope*
(*island-scope* disposable))))