Files
rose-ash/shared/sx/ref/signals.sx
giles 06adbdcd59 Remove redundant features: ref sugar, suspense, transitions
- ref/ref-get/ref-set! functions removed (just dict wrappers — use dict
  primitives directly). The :ref attribute stays in adapter-dom.sx.
- Suspense form removed (if/when + deref on resource signals covers it)
- Transition function removed (fine-grained signals already avoid jank)
- Kept: error-boundary, resource, portal, :ref attribute

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 16:54:40 +00:00

422 lines
16 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
;;
;; 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
(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 (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
(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 (invoke 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 (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) (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
(fn (thunk)
(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)
(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))))
;; ==========================================================================
;; 12. 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
(fn (name init-fn)
(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
(fn (name)
(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
(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
(fn (el event-name detail)
(dom-dispatch el event-name detail)))
(define on-event
(fn (el event-name handler)
(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
(fn (el event-name target-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
(fn (fetch-fn)
(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)))