Fix WASM browser: broken links (&rest bytecode) + broken reactive counter (ListRef mutation)

Two bugs fixed:

1. Links: bytecode compiler doesn't handle &rest params — treats them as
   positional, so (first rest) gets a raw string instead of a list.
   Replaced &rest with explicit optional params in all bytecode-compiled
   web SX files (dom-query, dom-add-listener, browser-push-state, etc.).
   The VM already pads missing args with Nil.

2. Reactive counter: signal-remove-sub! used (filter ...) which returns
   immutable List, but signal-add-sub! uses (append!) which only mutates
   ListRef. Subscribers silently vanished after first effect re-run.
   Fixed by adding remove! primitive that mutates ListRef in-place.

Also:
- Added evalVM API to WASM kernel (compile + run through bytecode VM)
- Added scope tracing (scope-push!/pop!/peek/context instrumentation)
- Added Playwright reactive mode for debugging island signal/DOM state
- Replaced cek-call with direct calls in core-signals.sx effect/computed
- Recompiled all 23 bytecode modules

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-27 14:08:49 +00:00
parent 553bbf123e
commit 8d3ab040ef
18 changed files with 42899 additions and 3236 deletions

View File

@@ -247,6 +247,34 @@ let api_eval src_js =
| Eval_error msg -> Js.Unsafe.inject (Js.string ("Error: " ^ msg))
| Parse_error msg -> Js.Unsafe.inject (Js.string ("Parse error: " ^ msg))
(** evalVM: compile SX source to bytecode and run through the VM.
Globals defined with `define` are visible to subsequent evalVM/eval calls.
This tests the exact same code path as island hydration and click handlers. *)
let api_eval_vm src_js =
let src = Js.to_string src_js in
try
let exprs = Sx_parser.parse_all src in
let compile_fn = match Hashtbl.find_opt _vm_globals "compile-module" with
| Some v -> v
| None -> env_get global_env "compile-module" in
let code_val = Sx_runtime.trampoline (Sx_runtime.sx_call compile_fn [List exprs]) in
let code = Sx_vm.code_from_value code_val in
let result = Sx_vm.execute_module code _vm_globals in
(* Sync VM globals → CEK env so subsequent eval() calls see defines *)
Hashtbl.iter (fun name v ->
let id = intern name in
if not (Hashtbl.mem global_env.bindings id) then
Hashtbl.replace global_env.bindings id v
else (match Hashtbl.find global_env.bindings id, v with
| VmClosure _, VmClosure _ | _, VmClosure _ -> Hashtbl.replace global_env.bindings id v
| _ -> ())
) _vm_globals;
return_via_side_channel (value_to_js result)
with
| Eval_error msg -> Js.Unsafe.inject (Js.string ("Error: " ^ msg))
| Parse_error msg -> Js.Unsafe.inject (Js.string ("Parse error: " ^ msg))
| Not_found -> Js.Unsafe.inject (Js.string "Error: compile-module not loaded")
let api_eval_expr expr_js _env_js =
let expr = js_to_value expr_js in
try
@@ -427,12 +455,11 @@ let () =
bind "cek-call" (fun args ->
match args with
| [f; Nil] -> Sx_ref.eval_expr (List [f]) (Env global_env)
| [f; List al] -> Sx_ref.eval_expr (List (f :: al)) (Env global_env)
| [f; a] -> Sx_ref.eval_expr (List [f; a]) (Env global_env)
| [f] -> Sx_ref.eval_expr (List [f]) (Env global_env)
| f :: rest -> Sx_ref.eval_expr (List (f :: rest)) (Env global_env)
| _ -> raise (Eval_error "cek-call: expected function and args"));
| [f; a] when is_callable f ->
let arg_list = match a with List l -> l | Nil -> [] | v -> [v] in
Sx_runtime.trampoline (Sx_runtime.sx_call f arg_list)
| [f; _] -> raise (Eval_error ("cek-call: not callable: " ^ type_of f))
| _ -> raise (Eval_error "cek-call: expected (fn args)"));
bind "sx-parse" (fun args ->
match args with
@@ -481,6 +508,15 @@ let () =
match args with
| [ListRef r; v] -> r := !r @ [v]; ListRef r
| [List items; v] -> List (items @ [v])
| _ -> raise (Eval_error "append!: expected list and value"));
(* remove! — mutate ListRef in-place, removing by identity (==) *)
bind "remove!" (fun args ->
match args with
| [ListRef r; target] ->
r := List.filter (fun x -> x != target) !r; ListRef r
| [List items; target] ->
List (List.filter (fun x -> x != target) items)
| _ -> raise (Eval_error "append!: list and value"));
(* --- Environment ops --- *)
@@ -602,11 +638,17 @@ let () =
match args with [mac; raw; Env e] -> Sx_ref.expand_macro mac raw (Env e) | [mac; raw] -> Sx_ref.expand_macro mac raw (Env global_env) | _ -> Nil);
bind "call-lambda" (fun args ->
match args with
| [fn_val; call_args; Env _e] -> Sx_ref.cek_call fn_val call_args
| [fn_val; call_args] -> Sx_ref.cek_call fn_val call_args
| [f; a; _] | [f; a] when is_callable f ->
let arg_list = match a with List l -> l | Nil -> [] | v -> [v] in
Sx_runtime.trampoline (Sx_runtime.sx_call f arg_list)
| _ -> raise (Eval_error "call-lambda: expected (fn args env?)"));
bind "cek-call" (fun args ->
match args with [f; a] -> Sx_ref.cek_call f a | _ -> raise (Eval_error "cek-call"));
match args with
| [f; a] when is_callable f ->
let arg_list = match a with List l -> l | Nil -> [] | v -> [v] in
Sx_runtime.trampoline (Sx_runtime.sx_call f arg_list)
| [f; _] -> raise (Eval_error ("cek-call: not callable: " ^ type_of f))
| _ -> raise (Eval_error "cek-call: expected (fn args)"));
bind "cek-eval" (fun args ->
match args with [expr] -> Sx_ref.eval_expr expr (Env global_env) | [expr; e] -> Sx_ref.eval_expr expr e | _ -> Nil);
bind "qq-expand-runtime" (fun args ->
@@ -720,6 +762,7 @@ let () =
Js.Unsafe.set sx (Js.string "parse") (Js.wrap_callback api_parse);
Js.Unsafe.set sx (Js.string "stringify") (Js.wrap_callback api_stringify);
Js.Unsafe.set sx (Js.string "eval") (wrap api_eval);
Js.Unsafe.set sx (Js.string "evalVM") (wrap api_eval_vm);
Js.Unsafe.set sx (Js.string "evalExpr") (wrap api_eval_expr);
Js.Unsafe.set sx (Js.string "renderToHtml") (Js.wrap_callback api_render_to_html);
Js.Unsafe.set sx (Js.string "load") (Js.wrap_callback api_load);
@@ -737,4 +780,13 @@ let () =
Js.Unsafe.set sx (Js.string "fnArity") (Js.wrap_callback api_fn_arity);
Js.Unsafe.set sx (Js.string "debugEnv") (Js.wrap_callback api_debug_env);
(* Scope tracing API *)
Js.Unsafe.set sx (Js.string "scopeTraceOn") (Js.wrap_callback (fun () ->
Sx_scope.scope_trace_enable (); Js.Unsafe.inject Js.null));
Js.Unsafe.set sx (Js.string "scopeTraceOff") (Js.wrap_callback (fun () ->
Sx_scope.scope_trace_disable (); Js.Unsafe.inject Js.null));
Js.Unsafe.set sx (Js.string "scopeTraceDrain") (Js.wrap_callback (fun () ->
let log = Sx_scope.scope_trace_drain () in
Js.Unsafe.inject (Js.array (Array.of_list (List.map (fun s -> Js.Unsafe.inject (Js.string s)) log)))));
Js.Unsafe.set Js.Unsafe.global (Js.string "SxKernel") sx

View File

@@ -12,6 +12,16 @@ open Sx_types
Used by aser for spread/provide/emit patterns, CSSX collect/flush, etc. *)
let scope_stacks : (string, value list) Hashtbl.t = Hashtbl.create 8
(** Debug trace for scope operations — enabled from JS *)
let _scope_trace = ref false
let _scope_log : string list ref = ref []
let scope_trace_enable () = _scope_trace := true; _scope_log := []
let scope_trace_disable () = _scope_trace := false
let scope_trace_drain () =
let log = List.rev !_scope_log in
_scope_log := [];
log
(** Request cookies — set by the Python bridge before each render.
get-cookie reads from here; set-cookie is a no-op on the server. *)
let request_cookies : (string, string) Hashtbl.t = Hashtbl.create 8
@@ -40,6 +50,8 @@ let () =
match args with
| [String name; value] ->
let stack = try Hashtbl.find scope_stacks name with Not_found -> [] in
if !_scope_trace then
_scope_log := Printf.sprintf "PUSH %s depth=%d->%d" name (List.length stack) (List.length stack + 1) :: !_scope_log;
Hashtbl.replace scope_stacks name (value :: stack); Nil
| _ -> Nil);
@@ -47,6 +59,8 @@ let () =
match args with
| [String name] ->
let stack = try Hashtbl.find scope_stacks name with Not_found -> [] in
if !_scope_trace then
_scope_log := Printf.sprintf "POP %s depth=%d->%d" name (List.length stack) (max 0 (List.length stack - 1)) :: !_scope_log;
(match stack with _ :: rest -> Hashtbl.replace scope_stacks name rest | [] -> ()); Nil
| _ -> Nil);
@@ -54,6 +68,8 @@ let () =
match args with
| [String name] ->
let stack = try Hashtbl.find scope_stacks name with Not_found -> [] in
if !_scope_trace then
_scope_log := Printf.sprintf "PEEK %s depth=%d found=%b" name (List.length stack) (stack <> []) :: !_scope_log;
(match stack with v :: _ -> v | [] -> Nil)
| _ -> Nil);
@@ -63,6 +79,8 @@ let () =
match args with
| [String name] | [String name; _] ->
let stack = try Hashtbl.find scope_stacks name with Not_found -> [] in
if !_scope_trace then
_scope_log := Printf.sprintf "CTX %s depth=%d found=%b" name (List.length stack) (stack <> []) :: !_scope_log;
(match stack, args with
| v :: _, _ -> v
| [], [_; default_val] -> default_val