diff --git a/hosts/ocaml/bin/run_tests.ml b/hosts/ocaml/bin/run_tests.ml index 7d947b6d..acd0314f 100644 --- a/hosts/ocaml/bin/run_tests.ml +++ b/hosts/ocaml/bin/run_tests.ml @@ -756,7 +756,8 @@ let run_spec_tests env test_files = load_module "bytecode.sx" lib_dir; load_module "compiler.sx" lib_dir; load_module "vm.sx" lib_dir; - load_module "signals.sx" web_dir; + load_module "signals.sx" spec_dir; (* core reactive primitives *) + load_module "signals.sx" web_dir; (* web extensions *) load_module "freeze.sx" lib_dir; load_module "content.sx" lib_dir; load_module "types.sx" lib_dir; diff --git a/hosts/ocaml/bin/sx_server.ml b/hosts/ocaml/bin/sx_server.ml index 47050fd0..1ad9aea7 100644 --- a/hosts/ocaml/bin/sx_server.ml +++ b/hosts/ocaml/bin/sx_server.ml @@ -1205,6 +1205,7 @@ let test_mode () = Filename.concat spec_base "parser.sx"; Filename.concat spec_base "render.sx"; Filename.concat lib_base "compiler.sx"; + Filename.concat spec_base "signals.sx"; Filename.concat web_base "signals.sx"; Filename.concat web_base "adapter-html.sx"; Filename.concat web_base "adapter-sx.sx"; diff --git a/hosts/ocaml/browser/bundle.sh b/hosts/ocaml/browser/bundle.sh index 76b0677b..f9682c85 100755 --- a/hosts/ocaml/browser/bundle.sh +++ b/hosts/ocaml/browser/bundle.sh @@ -27,6 +27,7 @@ cp "$BUILD/sx_browser.bc.js" "$DIST/" cp sx-platform.js "$DIST/" # 3. Spec modules +cp "$ROOT/spec/signals.sx" "$DIST/sx/core-signals.sx" cp "$ROOT/spec/render.sx" "$DIST/sx/" cp "$ROOT/web/signals.sx" "$DIST/sx/" cp "$ROOT/web/deps.sx" "$DIST/sx/" diff --git a/hosts/ocaml/browser/sx-platform.js b/hosts/ocaml/browser/sx-platform.js index 59a1d119..097e3b95 100644 --- a/hosts/ocaml/browser/sx-platform.js +++ b/hosts/ocaml/browser/sx-platform.js @@ -234,6 +234,7 @@ var files = [ // Spec modules "sx/render.sx", + "sx/core-signals.sx", "sx/signals.sx", "sx/deps.sx", "sx/router.sx", diff --git a/shared/sx/ocaml_bridge.py b/shared/sx/ocaml_bridge.py index 5613fb3a..7f48b229 100644 --- a/shared/sx/ocaml_bridge.py +++ b/shared/sx/ocaml_bridge.py @@ -384,7 +384,14 @@ class OcamlBridge: # All directories loaded into the Python env all_dirs = list(set(_watched_dirs) | _dirs_from_cache) - # Isomorphic libraries: signals, rendering, web forms + # Core spec: signals (must load before web/signals.sx extensions) + spec_dir = os.path.join(os.path.dirname(__file__), "../../spec") + if os.path.isdir(spec_dir): + for spec_file in ["signals.sx"]: + path = os.path.normpath(os.path.join(spec_dir, spec_file)) + if os.path.isfile(path): + all_files.append(path) + # Isomorphic libraries: signals extensions, rendering, web forms web_dir = os.path.join(os.path.dirname(__file__), "../../web") if os.path.isdir(web_dir): for web_file in ["signals.sx", "adapter-html.sx", "adapter-sx.sx", diff --git a/spec/signals.sx b/spec/signals.sx new file mode 100644 index 00000000..1560a0d8 --- /dev/null +++ b/spec/signals.sx @@ -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)))))) diff --git a/web/signals.sx b/web/signals.sx index 7ad1db82..14d4280b 100644 --- a/web/signals.sx +++ b/web/signals.sx @@ -1,385 +1,45 @@ ;; ========================================================================== -;; signals.sx — Reactive signal runtime specification +;; web/signals.sx — Web platform signal extensions ;; -;; 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. -;; -;; Signals are plain dicts with a "__signal" marker key. No platform -;; primitives needed — all signal operations are pure SX. -;; -;; Reactive tracking and island lifecycle use the general scoped effects -;; system (scope-push!/scope-pop!/context) instead of separate globals. -;; Two scope names: -;; "sx-reactive" — tracking context for computed/effect dep discovery -;; "sx-island-scope" — island disposable collector -;; -;; 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. -;; Dispatches through cek-run for SX -;; lambdas, apply for native callables. -;; Defined in cek.sx. +;; Extends the core reactive signal spec (spec/signals.sx) with web-specific +;; features: marsh scopes (DOM lifecycle), named stores (page-level state), +;; event bridge (lake→island communication), and async resources. ;; +;; These depend on platform primitives: +;; dom-set-data, dom-get-data, dom-listen, dom-dispatch, event-detail, +;; promise-then ;; ========================================================================== ;; -------------------------------------------------------------------------- -;; Signal container — plain dict with marker key +;; Marsh scopes — child scopes within islands ;; -------------------------------------------------------------------------- -;; -;; A signal is a dict: {"__signal" true, "value" v, "subscribers" [], "deps" []} -;; type-of returns "dict". Use signal? to distinguish from regular dicts. - -(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))) - - -;; -------------------------------------------------------------------------- -;; 1. signal — create a reactive container -;; -------------------------------------------------------------------------- - -(define signal :effects [] - (fn ((initial-value :as any)) - (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 :effects [] - (fn ((s :as any)) - (if (not (signal? s)) - s ;; non-signal values pass through - (let ((ctx (context "sx-reactive" nil))) - (when ctx - ;; Register this signal as a dependency of the current context - (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))))) - - -;; -------------------------------------------------------------------------- -;; 3. 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)))))) - - -;; -------------------------------------------------------------------------- -;; 4. 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)))))) - - -;; -------------------------------------------------------------------------- -;; 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 :effects [mutation] - (fn ((compute-fn :as lambda)) - (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 :as signal)) (signal-remove-sub! dep recompute)) - (signal-deps s)) - (signal-set-deps! s (list)) - - ;; Push scope-based tracking context for this computed - (let ((ctx (dict "deps" (list) "notify" recompute))) - (scope-push! "sx-reactive" ctx) - (let ((new-val (cek-call compute-fn nil))) - (scope-pop! "sx-reactive") - ;; Save discovered deps - (signal-set-deps! s (get ctx "deps")) - ;; 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 :effects [mutation] - (fn ((effect-fn :as lambda)) - (let ((deps (list)) - (disposed false) - (cleanup-fn nil)) - - (let ((run-effect - (fn () - (when (not disposed) - ;; Run previous cleanup if any - (when cleanup-fn (cek-call cleanup-fn nil)) - - ;; Unsubscribe from old deps - (for-each - (fn ((dep :as signal)) (signal-remove-sub! dep run-effect)) - deps) - (set! deps (list)) - - ;; Push scope-based tracking context - (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")) - ;; 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 (cek-call cleanup-fn nil)) - (for-each - (fn ((dep :as signal)) (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 :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)) - ;; Collect unique subscribers across all queued signals, - ;; then notify each exactly once. - (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)))))) - - -;; -------------------------------------------------------------------------- -;; 8. notify-subscribers — internal notification dispatch -;; -------------------------------------------------------------------------- -;; -;; If inside a batch, queues the signal. Otherwise, notifies immediately. - -(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)))) - - -;; -------------------------------------------------------------------------- -;; 9. Reactive tracking context -;; -------------------------------------------------------------------------- -;; -;; Tracking is now scope-based. computed/effect push a dict -;; {:deps (list) :notify fn} onto the "sx-reactive" scope stack via -;; scope-push!/scope-pop!. deref reads it via (context "sx-reactive" nil). -;; No platform primitives needed — uses the existing scope infrastructure. - - -;; -------------------------------------------------------------------------- -;; 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 :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))))) - - -;; -------------------------------------------------------------------------- -;; 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. -;; -;; Uses "sx-island-scope" scope name. The scope value is a collector -;; function (fn (disposable) ...) that appends to the island's disposer list. - -(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))) - -;; Hook into signal/effect/computed creation for scope tracking. - -(define register-in-scope :effects [mutation] - (fn ((disposable :as lambda)) - (let ((collector (context "sx-island-scope" nil))) - (when collector - (cek-call collector (list disposable)))))) - - -;; ========================================================================== -;; 12. Marsh scopes — child scopes within islands -;; ========================================================================== -;; -;; Marshes are zones inside islands where server content is re-evaluated -;; in the island's reactive context. When a marsh is re-morphed with new -;; content, its old effects and computeds must be disposed WITHOUT disturbing -;; the island's own reactive graph. -;; -;; Scope hierarchy: island → marsh → effects/computeds -;; Disposing a marsh disposes its subscope. Disposing an island disposes -;; all its marshes. The signal graph is a tree, not a flat list. -;; -;; Platform interface required: -;; (dom-set-data el key val) → void — store JS value on element -;; (dom-get-data el key) → any — retrieve stored value (define with-marsh-scope :effects [mutation io] (fn (marsh-el (body-fn :as lambda)) - ;; Execute body-fn collecting all disposables into a marsh-local list. - ;; Nested under the current island scope — if the island is disposed, - ;; the marsh is disposed too (because island scope collected the marsh's - ;; own dispose function). (let ((disposers (list))) (with-island-scope (fn (d) (append! disposers d)) body-fn) - ;; Store disposers on the marsh element for later cleanup (dom-set-data marsh-el "sx-marsh-disposers" disposers)))) (define dispose-marsh-scope :effects [mutation io] (fn (marsh-el) - ;; Dispose all effects/computeds registered in this marsh's scope. - ;; Parent island scope and sibling marshes are unaffected. (let ((disposers (dom-get-data marsh-el "sx-marsh-disposers"))) (when disposers (for-each (fn ((d :as lambda)) (cek-call d nil)) disposers) (dom-set-data marsh-el "sx-marsh-disposers" nil))))) -;; ========================================================================== -;; 13. 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. +;; -------------------------------------------------------------------------- +;; Named stores — page-level signal containers +;; -------------------------------------------------------------------------- (define *store-registry* (dict)) (define def-store :effects [mutation] (fn ((name :as string) (init-fn :as lambda)) (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 (cek-call init-fn nil)))) (get *store-registry* name)))) @@ -396,28 +56,9 @@ (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. +;; -------------------------------------------------------------------------- +;; Event bridge — DOM event communication for lake→island +;; -------------------------------------------------------------------------- (define emit-event :effects [io] (fn (el (event-name :as string) detail) @@ -427,12 +68,6 @@ (fn (el (event-name :as string) (handler :as lambda)) (dom-on 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 :effects [mutation io] (fn (el (event-name :as string) (target-signal :as signal) transform-fn) (effect (fn () @@ -443,37 +78,20 @@ (cek-call transform-fn (list 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 +;; -------------------------------------------------------------------------- +;; Resource — async signal with loading/resolved/error states +;; -------------------------------------------------------------------------- (define resource :effects [mutation io] (fn ((fetch-fn :as lambda)) (let ((state (signal (dict "loading" true "data" nil "error" nil)))) - ;; Kick off the async operation - (promise-then (cek-call fetch-fn nil) - (fn (data) (reset! state (dict "loading" false "data" data "error" nil))) - (fn (err) (reset! state (dict "loading" false "data" nil "error" err)))) + (promise-then + (cek-call fetch-fn nil) + (fn (data) + (reset! state (dict "loading" false "data" data "error" nil))) + (fn (err) + (reset! state (dict "loading" false "data" nil "error" err)))) state))) - -