Island SSR: defislands render to HTML server-side with hydration markers
Islands now render their initial state as HTML on the server, like React SSR. The client hydrates with reactive behavior on boot. Root causes fixed: - is_signal/signal_value now recognize Dict-based signals (from signals.sx) in addition to native Signal values - Register "context" as a primitive so the CEK deref frame handler can read scope stacks for reactive tracking - Load adapter-html.sx into kernel for SX-level render-to-html (islands use this instead of the OCaml render module) - Component accessors (params, body, has-children?, affinity) handle Island values with ? suffix aliases - Add platform primitives: make-raw-html, raw-html-content, empty-dict?, for-each-indexed, cek-call Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -17,6 +17,7 @@ services:
|
|||||||
SX_OCAML_BIN: "/app/bin/sx_server"
|
SX_OCAML_BIN: "/app/bin/sx_server"
|
||||||
SX_BOUNDARY_STRICT: "1"
|
SX_BOUNDARY_STRICT: "1"
|
||||||
SX_DEV: "1"
|
SX_DEV: "1"
|
||||||
|
OCAMLRUNPARAM: "b"
|
||||||
ports:
|
ports:
|
||||||
- "8013:8000"
|
- "8013:8000"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -150,6 +150,16 @@ let () = Sx_primitives.register "scope-peek" (fun args ->
|
|||||||
(match stack with v :: _ -> v | [] -> Nil)
|
(match stack with v :: _ -> v | [] -> Nil)
|
||||||
| _ -> Nil)
|
| _ -> Nil)
|
||||||
|
|
||||||
|
let () = Sx_primitives.register "context" (fun args ->
|
||||||
|
match args with
|
||||||
|
| [String name] | [String name; _] ->
|
||||||
|
let stack = try Hashtbl.find _scope_stacks name with Not_found -> [] in
|
||||||
|
(match stack, args with
|
||||||
|
| v :: _, _ -> v
|
||||||
|
| [], [_; default_val] -> default_val
|
||||||
|
| [], _ -> Nil)
|
||||||
|
| _ -> Nil)
|
||||||
|
|
||||||
(** collect! — lazy scope accumulator. Creates root scope if missing,
|
(** collect! — lazy scope accumulator. Creates root scope if missing,
|
||||||
emits value (deduplicates). Used by cssx and spread components. *)
|
emits value (deduplicates). Used by cssx and spread components. *)
|
||||||
let () = Sx_primitives.register "collect!" (fun args ->
|
let () = Sx_primitives.register "collect!" (fun args ->
|
||||||
@@ -376,13 +386,29 @@ let make_server_env () =
|
|||||||
| [List items; v] -> List (items @ [v])
|
| [List items; v] -> List (items @ [v])
|
||||||
| _ -> raise (Eval_error "append!: expected list and value"));
|
| _ -> raise (Eval_error "append!: expected list and value"));
|
||||||
|
|
||||||
(* HTML renderer *)
|
(* HTML renderer — OCaml render module provides the shell renderer;
|
||||||
|
adapter-html.sx provides the SX-level render-to-html *)
|
||||||
Sx_render.setup_render_env env;
|
Sx_render.setup_render_env env;
|
||||||
|
|
||||||
(* Render-mode flags *)
|
(* Render-mode flags *)
|
||||||
bind "set-render-active!" (fun _args -> Nil);
|
bind "set-render-active!" (fun _args -> Nil);
|
||||||
bind "render-active?" (fun _args -> Bool true);
|
bind "render-active?" (fun _args -> Bool true);
|
||||||
|
|
||||||
|
(* Raw HTML — platform primitives for adapter-html.sx *)
|
||||||
|
bind "make-raw-html" (fun args ->
|
||||||
|
match args with [String s] -> RawHTML s | [v] -> RawHTML (value_to_string v) | _ -> Nil);
|
||||||
|
bind "raw-html-content" (fun args ->
|
||||||
|
match args with [RawHTML s] -> String s | [String s] -> String s | _ -> String "");
|
||||||
|
bind "empty-dict?" (fun args ->
|
||||||
|
match args with [Dict d] -> Bool (Hashtbl.length d = 0) | _ -> Bool true);
|
||||||
|
bind "for-each-indexed" (fun args ->
|
||||||
|
match args with
|
||||||
|
| [fn_val; List items] | [fn_val; ListRef { contents = items }] ->
|
||||||
|
List.iteri (fun i item ->
|
||||||
|
ignore (Sx_ref.eval_expr (List [fn_val; Number (float_of_int i); item]) (Env env))
|
||||||
|
) items; Nil
|
||||||
|
| _ -> Nil);
|
||||||
|
|
||||||
(* Scope stack — platform primitives for render-time dynamic scope.
|
(* Scope stack — platform primitives for render-time dynamic scope.
|
||||||
Used by aser for spread/provide/emit patterns.
|
Used by aser for spread/provide/emit patterns.
|
||||||
Module-level so step-sf-context can check it via get-primitive. *)
|
Module-level so step-sf-context can check it via get-primitive. *)
|
||||||
@@ -764,21 +790,27 @@ let make_server_env () =
|
|||||||
bind "component-params" (fun args ->
|
bind "component-params" (fun args ->
|
||||||
match args with
|
match args with
|
||||||
| [Component c] -> List (List.map (fun s -> String s) c.c_params)
|
| [Component c] -> List (List.map (fun s -> String s) c.c_params)
|
||||||
|
| [Island i] -> List (List.map (fun s -> String s) i.i_params)
|
||||||
| _ -> Nil);
|
| _ -> Nil);
|
||||||
|
|
||||||
bind "component-body" (fun args ->
|
bind "component-body" (fun args ->
|
||||||
match args with
|
match args with
|
||||||
| [Component c] -> c.c_body
|
| [Component c] -> c.c_body
|
||||||
|
| [Island i] -> i.i_body
|
||||||
| _ -> Nil);
|
| _ -> Nil);
|
||||||
|
|
||||||
bind "component-has-children" (fun args ->
|
let has_children_impl = NativeFn ("component-has-children?", fun args ->
|
||||||
match args with
|
match args with
|
||||||
| [Component c] -> Bool c.c_has_children
|
| [Component c] -> Bool c.c_has_children
|
||||||
| _ -> Bool false);
|
| [Island i] -> Bool i.i_has_children
|
||||||
|
| _ -> Bool false) in
|
||||||
|
ignore (env_bind env "component-has-children" has_children_impl);
|
||||||
|
ignore (env_bind env "component-has-children?" has_children_impl);
|
||||||
|
|
||||||
bind "component-affinity" (fun args ->
|
bind "component-affinity" (fun args ->
|
||||||
match args with
|
match args with
|
||||||
| [Component c] -> String c.c_affinity
|
| [Component c] -> String c.c_affinity
|
||||||
|
| [Island _] -> String "client"
|
||||||
| _ -> String "auto");
|
| _ -> String "auto");
|
||||||
|
|
||||||
bind "keyword-name" (fun args ->
|
bind "keyword-name" (fun args ->
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ let as_number = function
|
|||||||
| Bool false -> 0.0
|
| Bool false -> 0.0
|
||||||
| Nil -> 0.0
|
| Nil -> 0.0
|
||||||
| String s -> (match float_of_string_opt s with Some n -> n | None -> Float.nan)
|
| String s -> (match float_of_string_opt s with Some n -> n | None -> Float.nan)
|
||||||
| v -> raise (Eval_error ("Expected number, got " ^ type_of v))
|
| v -> raise (Eval_error ("Expected number, got " ^ type_of v ^ ": " ^ (match v with Dict d -> (match Hashtbl.find_opt d "__signal" with Some _ -> "signal{value=" ^ (match Hashtbl.find_opt d "value" with Some v' -> value_to_string v' | None -> "?") ^ "}" | None -> "dict") | _ -> "")))
|
||||||
|
|
||||||
let as_string = function
|
let as_string = function
|
||||||
| String s -> s
|
| String s -> s
|
||||||
|
|||||||
@@ -256,17 +256,20 @@ and render_list_to_html head args env =
|
|||||||
let v = env_get env name in
|
let v = env_get env name in
|
||||||
(match v with
|
(match v with
|
||||||
| Component _ -> render_component v args env
|
| Component _ -> render_component v args env
|
||||||
| Island i ->
|
| Island _i ->
|
||||||
(* Islands: render initial HTML server-side (like React SSR).
|
(* Islands: SSR via the SX render-to-html from adapter-html.sx.
|
||||||
Log failures so we can fix them. *)
|
It handles deref/signal/computed through the CEK correctly,
|
||||||
|
and renders island bodies with hydration markers. *)
|
||||||
(try
|
(try
|
||||||
let c = { c_name = i.i_name; c_params = i.i_params;
|
let call_expr = List (Symbol name :: args) in
|
||||||
c_has_children = i.i_has_children; c_body = i.i_body;
|
let quoted = List [Symbol "quote"; call_expr] in
|
||||||
c_closure = i.i_closure; c_affinity = "client";
|
let render_call = List [Symbol "render-to-html"; quoted; Env env] in
|
||||||
c_compiled = None } in
|
let result = Sx_ref.eval_expr render_call (Env env) in
|
||||||
render_component (Component c) args env
|
(match result with
|
||||||
|
| String s | RawHTML s -> s
|
||||||
|
| _ -> value_to_string result)
|
||||||
with e ->
|
with e ->
|
||||||
Printf.eprintf "[ssr-island] ~%s FAILED: %s\n%!" i.i_name (Printexc.to_string e);
|
Printf.eprintf "[ssr-island] ~%s FAILED: %s\n%s\n%!" _i.i_name (Printexc.to_string e) (Printexc.get_backtrace ());
|
||||||
"")
|
"")
|
||||||
| Macro m ->
|
| Macro m ->
|
||||||
let expanded = expand_macro m args env in
|
let expanded = expand_macro m args env in
|
||||||
|
|||||||
@@ -346,7 +346,10 @@ let is_else_clause v =
|
|||||||
| _ -> Bool false
|
| _ -> Bool false
|
||||||
|
|
||||||
(* Signal accessors *)
|
(* Signal accessors *)
|
||||||
let signal_value s = match s with Signal sig' -> sig'.s_value | _ -> raise (Eval_error "not a signal")
|
let signal_value s = match s with
|
||||||
|
| Signal sig' -> sig'.s_value
|
||||||
|
| Dict d -> (match Hashtbl.find_opt d "value" with Some v -> v | None -> Nil)
|
||||||
|
| _ -> raise (Eval_error "not a signal")
|
||||||
let signal_set_value s v = match s with Signal sig' -> sig'.s_value <- v; v | _ -> raise (Eval_error "not a signal")
|
let signal_set_value s v = match s with Signal sig' -> sig'.s_value <- v; v | _ -> raise (Eval_error "not a signal")
|
||||||
let signal_subscribers s = match s with Signal sig' -> List (List.map (fun _ -> Nil) sig'.s_subscribers) | _ -> List []
|
let signal_subscribers s = match s with Signal sig' -> List (List.map (fun _ -> Nil) sig'.s_subscribers) | _ -> List []
|
||||||
let signal_add_sub_b _s _f = Nil
|
let signal_add_sub_b _s _f = Nil
|
||||||
|
|||||||
@@ -300,7 +300,10 @@ let is_component = function Component _ -> true | _ -> false
|
|||||||
let is_island = function Island _ -> true | _ -> false
|
let is_island = function Island _ -> true | _ -> false
|
||||||
let is_macro = function Macro _ -> true | _ -> false
|
let is_macro = function Macro _ -> true | _ -> false
|
||||||
let is_thunk = function Thunk _ -> true | _ -> false
|
let is_thunk = function Thunk _ -> true | _ -> false
|
||||||
let is_signal = function Signal _ -> true | _ -> false
|
let is_signal = function
|
||||||
|
| Signal _ -> true
|
||||||
|
| Dict d -> Hashtbl.mem d "__signal"
|
||||||
|
| _ -> false
|
||||||
|
|
||||||
let is_callable = function
|
let is_callable = function
|
||||||
| Lambda _ | NativeFn _ | Continuation (_, _) -> true
|
| Lambda _ | NativeFn _ | Continuation (_, _) -> true
|
||||||
|
|||||||
@@ -401,7 +401,7 @@ class OcamlBridge:
|
|||||||
# signals.sx provides reactive primitives for island SSR)
|
# signals.sx provides reactive primitives for island SSR)
|
||||||
web_dir = os.path.join(os.path.dirname(__file__), "../../web")
|
web_dir = os.path.join(os.path.dirname(__file__), "../../web")
|
||||||
if os.path.isdir(web_dir):
|
if os.path.isdir(web_dir):
|
||||||
for web_file in ["signals.sx", "adapter-sx.sx"]:
|
for web_file in ["signals.sx", "adapter-html.sx", "adapter-sx.sx"]:
|
||||||
path = os.path.normpath(os.path.join(web_dir, web_file))
|
path = os.path.normpath(os.path.join(web_dir, web_file))
|
||||||
if os.path.isfile(path):
|
if os.path.isfile(path):
|
||||||
all_files.append(path)
|
all_files.append(path)
|
||||||
|
|||||||
Reference in New Issue
Block a user