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
| Thunk (body, closure_env) -> Sx_ref.eval_expr body (Env closure_env)
| other -> other);
(* client? returns false on server — overridden in browser via K.eval *)
ignore (env_bind env "client?" (NativeFn ("client?", fun _ -> Bool false)));
env

View File

@@ -439,6 +439,9 @@ let api_fn_arity fn_js =
let () =
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 --- *)
bind "cek-eval" (fun args ->
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"))
let _sx_trampoline_fn : (value -> value) ref =
ref (fun v -> v)
let _is_client : bool ref = ref false
let register name fn = Hashtbl.replace primitives name fn
@@ -664,6 +665,8 @@ let () =
match args with [String msg] -> raise (Eval_error msg)
| [a] -> raise (Eval_error (to_string a))
| _ -> 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 ->
let call f a =
match f with

View File

@@ -1792,7 +1792,7 @@
blake2_js_for_wasm_create: blake2_js_for_wasm_create};
}
(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
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

View File

@@ -17,8 +17,9 @@
;; content) flows through the island, around the rocks (reactive signals).
(defisland ~layouts/header (&key path)
(let ((families (list "violet" "rose" "blue" "emerald" "amber" "cyan" "red" "teal" "pink" "indigo"))
(idx (signal 0))
(shade (signal 500))
(store (if (client?) (def-store "header-color" (fn () {:idx (signal 0) :shade (signal 500)})) nil))
(idx (if store (get store "idx") (signal 0)))
(shade (if store (get store "shade") (signal 500)))
(current-family (computed (fn ()
(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")

View File

@@ -515,16 +515,14 @@
;; 14. Event bridge — lake→island communication via custom DOM events
(defisland ~reactive-islands/index/demo-event-bridge ()
(let ((container-ref (dict "current" nil))
(messages (signal (list)))
(_eff (schedule-idle (fn ()
(let ((el (get container-ref "current")))
(when el
(on-event el "inbox:message"
(fn (e)
(swap! messages (fn (old)
(append old (host-get (event-detail e) "text"))))))))))))
(div :ref container-ref
(let ((messages (signal (list)))
(_eff (effect (fn ()
(let ((cb (host-callback
(fn (e) (swap! messages (fn (old)
(append old (host-get (event-detail e) "text"))))))))
(host-call (dom-document) "addEventListener" "inbox:message" cb)
(fn () (host-call (dom-document) "removeEventListener" "inbox:message" cb)))))))
(div
(p :class "text-xs font-semibold text-stone-500 mb-2" "Event Bridge Demo")
(p :class "text-sm text-stone-600 mb-2"
"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 }) => {
// BUG: on-event handler receives CustomEvent but swap!/signal update doesn't propagate to DOM
test('event-bridge: sender triggers receiver update', async ({ page }) => {
await page.goto(BASE_URL + '/sx/(geography.(reactive.(examples.event-bridge-demo)))', { waitUntil: 'networkidle' });
await page.waitForTimeout(2000);

View File

@@ -177,7 +177,7 @@ test.describe('Isomorphic SSR', () => {
});
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' });
// Wait for header island to hydrate