Split signals: core spec (spec/signals.sx) + web extensions (web/signals.sx)
Core reactive primitives (signal, deref, reset!, swap!, computed, effect, batch, notify-subscribers, dispose-computed, with-island-scope, register-in-scope) moved to spec/signals.sx — pure SX, zero platform dependencies. Usable by any host: web, CLI, embedded, server, harness. Web extensions (marsh scopes, stores, event bridge, resource) remain in web/signals.sx, which now depends on spec/signals.sx. Updated all load paths: - hosts/ocaml/bin/sx_server.ml — loads spec/signals.sx before web/signals.sx - hosts/ocaml/bin/run_tests.ml — loads both in order - hosts/ocaml/browser/bundle.sh + sx-platform.js — loads core-signals.sx first - shared/sx/ocaml_bridge.py — loads spec/signals.sx before web extensions 1116/1116 OCaml tests pass. Browser reactive island preview works. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
258
spec/signals.sx
Normal file
258
spec/signals.sx
Normal file
@@ -0,0 +1,258 @@
|
||||
;; ==========================================================================
|
||||
;; 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))))))
|
||||
Reference in New Issue
Block a user