Fix event-bridge + add client? primitive + header store foundation

- Event-bridge: rewrite island to use document-level addEventListener
  via effect + host-callback, bypassing broken container-ref + schedule-idle.
  Also use host-get for event-detail (WASM host handles).

- Add client? primitive: false on server (sx_primitives._is_client ref),
  true in browser (sx_browser.ml sets ref). Enables SSR-safe conditional
  logic for client-only features like def-store.

- Header island: use def-store for idx/shade signals when client? is true,
  falling back to plain signals on server. Foundation for SPA nav state
  preservation (store registry persistence still needs work).

- Remove unused client? K.eval override from sx-platform.js.

100 passed, 1 skipped (isomorphic nav — store registry resets on SPA nav), 0 failed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-28 10:05:32 +00:00
parent 919ce927b1
commit 0cae1fbb6b
41 changed files with 22 additions and 16 deletions

View File

@@ -657,6 +657,8 @@ let make_server_env () =
match v with match v with
| Thunk (body, closure_env) -> Sx_ref.eval_expr body (Env closure_env) | Thunk (body, closure_env) -> Sx_ref.eval_expr body (Env closure_env)
| other -> other); | other -> other);
(* client? returns false on server — overridden in browser via K.eval *)
ignore (env_bind env "client?" (NativeFn ("client?", fun _ -> Bool false)));
env env

View File

@@ -439,6 +439,9 @@ let api_fn_arity fn_js =
let () = let () =
let bind name fn = ignore (env_bind global_env name (NativeFn (name, fn))) in let bind name fn = ignore (env_bind global_env name (NativeFn (name, fn))) in
(* client? returns true in browser — set the ref so the primitive returns true *)
Sx_primitives._is_client := true;
(* --- Evaluation --- *) (* --- Evaluation --- *)
bind "cek-eval" (fun args -> bind "cek-eval" (fun args ->
match args with match args with

View File

@@ -12,6 +12,7 @@ let _sx_call_fn : (value -> value list -> value) ref =
ref (fun _ _ -> raise (Eval_error "sx_call not initialized")) ref (fun _ _ -> raise (Eval_error "sx_call not initialized"))
let _sx_trampoline_fn : (value -> value) ref = let _sx_trampoline_fn : (value -> value) ref =
ref (fun v -> v) ref (fun v -> v)
let _is_client : bool ref = ref false
let register name fn = Hashtbl.replace primitives name fn let register name fn = Hashtbl.replace primitives name fn
@@ -664,6 +665,8 @@ let () =
match args with [String msg] -> raise (Eval_error msg) match args with [String msg] -> raise (Eval_error msg)
| [a] -> raise (Eval_error (to_string a)) | [a] -> raise (Eval_error (to_string a))
| _ -> raise (Eval_error "error: 1 arg")); | _ -> raise (Eval_error "error: 1 arg"));
(* client? — false by default (server); sx_browser.ml sets _is_client := true *)
register "client?" (fun _args -> Bool !_is_client);
register "apply" (fun args -> register "apply" (fun args ->
let call f a = let call f a =
match f with match f with

View File

@@ -1792,7 +1792,7 @@
blake2_js_for_wasm_create: blake2_js_for_wasm_create}; blake2_js_for_wasm_create: blake2_js_for_wasm_create};
} }
(globalThis)) (globalThis))
({"link":[["runtime-0db9b496",0],["prelude-d7e4b000",0],["stdlib-23ce0836",[]],["sx-64e6b16e",[2]],["jsoo_runtime-f96b44a8",[2]],["js_of_ocaml-651f6707",[2,4]],["dune__exe__Sx_browser-2ac146e9",[2,3,5]],["std_exit-10fb8830",[2]],["start-80fdb768",0]],"generated":(b=>{var ({"link":[["runtime-0db9b496",0],["prelude-d7e4b000",0],["stdlib-23ce0836",[]],["sx-951e6734",[2]],["jsoo_runtime-f96b44a8",[2]],["js_of_ocaml-651f6707",[2,4]],["dune__exe__Sx_browser-6b156118",[2,3,5]],["std_exit-10fb8830",[2]],["start-80fdb768",0]],"generated":(b=>{var
c=b,a=b?.module?.export||b;return{"env":{"caml_ba_kind_of_typed_array":()=>{throw new c=b,a=b?.module?.export||b;return{"env":{"caml_ba_kind_of_typed_array":()=>{throw new
Error("caml_ba_kind_of_typed_array not implemented")},"caml_exn_with_js_backtrace":()=>{throw new Error("caml_ba_kind_of_typed_array not implemented")},"caml_exn_with_js_backtrace":()=>{throw new
Error("caml_exn_with_js_backtrace not implemented")},"caml_int64_create_lo_mi_hi":()=>{throw new Error("caml_exn_with_js_backtrace not implemented")},"caml_int64_create_lo_mi_hi":()=>{throw new

View File

@@ -17,8 +17,9 @@
;; content) flows through the island, around the rocks (reactive signals). ;; content) flows through the island, around the rocks (reactive signals).
(defisland ~layouts/header (&key path) (defisland ~layouts/header (&key path)
(let ((families (list "violet" "rose" "blue" "emerald" "amber" "cyan" "red" "teal" "pink" "indigo")) (let ((families (list "violet" "rose" "blue" "emerald" "amber" "cyan" "red" "teal" "pink" "indigo"))
(idx (signal 0)) (store (if (client?) (def-store "header-color" (fn () {:idx (signal 0) :shade (signal 500)})) nil))
(shade (signal 500)) (idx (if store (get store "idx") (signal 0)))
(shade (if store (get store "shade") (signal 500)))
(current-family (computed (fn () (current-family (computed (fn ()
(nth families (mod (deref idx) (len families))))))) (nth families (mod (deref idx) (len families)))))))
(div (~cssx/tw :tokens "block max-w-3xl mx-auto px-4 pt-8 pb-4 text-center") (div (~cssx/tw :tokens "block max-w-3xl mx-auto px-4 pt-8 pb-4 text-center")

View File

@@ -515,16 +515,14 @@
;; 14. Event bridge — lake→island communication via custom DOM events ;; 14. Event bridge — lake→island communication via custom DOM events
(defisland ~reactive-islands/index/demo-event-bridge () (defisland ~reactive-islands/index/demo-event-bridge ()
(let ((container-ref (dict "current" nil)) (let ((messages (signal (list)))
(messages (signal (list))) (_eff (effect (fn ()
(_eff (schedule-idle (fn () (let ((cb (host-callback
(let ((el (get container-ref "current"))) (fn (e) (swap! messages (fn (old)
(when el (append old (host-get (event-detail e) "text"))))))))
(on-event el "inbox:message" (host-call (dom-document) "addEventListener" "inbox:message" cb)
(fn (e) (fn () (host-call (dom-document) "removeEventListener" "inbox:message" cb)))))))
(swap! messages (fn (old) (div
(append old (host-get (event-detail e) "text"))))))))))))
(div :ref container-ref
(p :class "text-xs font-semibold text-stone-500 mb-2" "Event Bridge Demo") (p :class "text-xs font-semibold text-stone-500 mb-2" "Event Bridge Demo")
(p :class "text-sm text-stone-600 mb-2" (p :class "text-sm text-stone-600 mb-2"
"The buttons below simulate server-rendered content dispatching events into the island.") "The buttons below simulate server-rendered content dispatching events into the island.")

View File

@@ -232,8 +232,7 @@ test.describe('Reactive island interactions', () => {
} }
}); });
test.fixme('event-bridge: sender triggers receiver update', async ({ page }) => { test('event-bridge: sender triggers receiver update', async ({ page }) => {
// BUG: on-event handler receives CustomEvent but swap!/signal update doesn't propagate to DOM
await page.goto(BASE_URL + '/sx/(geography.(reactive.(examples.event-bridge-demo)))', { waitUntil: 'networkidle' }); await page.goto(BASE_URL + '/sx/(geography.(reactive.(examples.event-bridge-demo)))', { waitUntil: 'networkidle' });
await page.waitForTimeout(2000); await page.waitForTimeout(2000);

View File

@@ -177,7 +177,7 @@ test.describe('Isomorphic SSR', () => {
}); });
test.fixme('navigation preserves header island state', async ({ page }) => { test.fixme('navigation preserves header island state', async ({ page }) => {
// BUG: header island inside #main-panel swap boundary — needs structural layout change or store-based state // BUG: def-store state not persisting — *store-registry* likely reset during SPA nav component reload
await page.goto(BASE_URL + '/sx/', { waitUntil: 'networkidle' }); await page.goto(BASE_URL + '/sx/', { waitUntil: 'networkidle' });
// Wait for header island to hydrate // Wait for header island to hydrate