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>
291 lines
10 KiB
Plaintext
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))))
|