host+kernel: blog SPA boost works end-to-end on the WASM OCaml kernel (Playwright 4/4)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 43s

Clicking a blog link now fragment-swaps #content with URL push + working back
button, no full reload — the SX-htmx engine driving the same OCaml kernel the
server runs. Six bugs in the source-load + boost path, found by bisecting in
chromium, all fixed:

1. Import double-apply (sx_server.ml x2, sx_browser.ml): the import suspension
   handlers computed `key = library_name_key lib_spec` then called
   `library_loaded_p key` — but library_loaded_p applies library_name_key
   itself, so it ran sx_to_list on a string and crashed ("Expected list, got
   string"). Only unloaded libs suspend, so it only bit lazy imports. Pass the
   spec, not the key.

2. Unloaded-import crash (spec/evaluator.sx + sx_ref.ml library_exports): an
   import of a not-yet-loaded library returned nil exports, and bind-import-set
   did (keys nil) -> crash. Return an empty dict so the import is a graceful
   no-op (lazy symbol resolution covers real usage).

3. value_to_js missing Integer (sx_browser.ml): integers passed to host methods
   were mishandled, so dom-query-all's (host-call node-list "item" i) ignored i
   and returned node 0 for every index — every element aliased the first, so
   only one link ever boosted. Add the Integer -> JS number case.

4. browser-same-origin? rejected relative URLs (browser.sx x2): it only did
   (starts-with? url origin), so "/alpha/" was treated as cross-origin and
   should-boost-link? refused every relative link. Accept scheme-less,
   non-protocol-relative URLs.

5. dom-query-in undefined (orchestration.sx x2): the swap path called a function
   that exists nowhere; it's just dom-query with a container arg.

6. Lazy-deps never loaded under source fallback (sx-platform.js): lazy symbol
   resolution only fires on the VM GLOBAL_GET path, but source-loaded swap
   callbacks run on the CEK and raise instead of lazy-loading, so the post-swap
   hs-boot-subtree!/htmx-boot-subtree! were undefined and aborted URL push.
   Preload the manifest's lazy-deps.

Verified: native host conformance 271/271; lib/host/playwright/spa-check 4/4
(boot, boost, fragment swap + URL push, back button) in real chromium against an
ephemeral durable host server.
This commit is contained in:
2026-06-29 11:09:11 +00:00
parent 05c0a0b01a
commit 689dae7d0c
12 changed files with 12987 additions and 5363 deletions

View File

@@ -537,8 +537,10 @@ and resolve_io_request request =
| "import" ->
(* Resolve library locally — load the .sx file *)
let lib_spec = Sx_runtime.get_val request (String "library") in
let key = Sx_ref.library_name_key lib_spec in
if Sx_types.sx_truthy (Sx_ref.library_loaded_p key) then
(* library_loaded_p takes the library SPEC and computes the key itself —
passing an already-computed key string double-applies library_name_key
and crashes (sx_to_list on a string). *)
if Sx_types.sx_truthy (Sx_ref.library_loaded_p lib_spec) then
(* Already loaded — just resume *)
Nil
else begin
@@ -1815,8 +1817,9 @@ let rec dispatch env cmd =
| _ -> "" in
let response = if op = "import" then begin
let lib_spec = Sx_runtime.get_val request (String "library") in
let key = Sx_ref.library_name_key lib_spec in
if Sx_types.sx_truthy (Sx_ref.library_loaded_p key) then Nil
(* pass the SPEC, not a pre-computed key — library_loaded_p applies
library_name_key itself (a key string would crash sx_to_list). *)
if Sx_types.sx_truthy (Sx_ref.library_loaded_p lib_spec) then Nil
else begin
(match resolve_library_path lib_spec with
| Some path -> load_library_file path | None -> ());

View File

@@ -73,6 +73,7 @@ let rec value_to_js (v : value) : Js.Unsafe.any =
| Nil -> Js.Unsafe.inject Js.null
| Bool b -> Js.Unsafe.inject (Js.bool b)
| Number n -> Js.Unsafe.inject (Js.number_of_float n)
| Integer n -> Js.Unsafe.inject (Js.number_of_float (float_of_int n))
| String s -> Js.Unsafe.inject (Js.string s)
| RawHTML s -> Js.Unsafe.inject (Js.string s)
| Symbol s ->
@@ -329,8 +330,9 @@ let handle_import_suspension request =
let lib_spec = match request with
| Dict d -> (match Hashtbl.find_opt d "library" with Some v -> v | _ -> Nil)
| _ -> Nil in
let key = Sx_ref.library_name_key lib_spec in
if Sx_types.sx_truthy (Sx_ref.library_loaded_p key) then
(* library_loaded_p takes the SPEC and applies library_name_key itself —
passing a pre-computed key string double-applies it and crashes. *)
if Sx_types.sx_truthy (Sx_ref.library_loaded_p lib_spec) then
Some Nil (* Already loaded — resume immediately *)
else
None (* Not loaded — JS platform must fetch it *)

View File

@@ -404,7 +404,7 @@ and library_loaded_p spec =
(* library-exports *)
and library_exports spec =
(get ((get (_library_registry_) ((library_name_key (spec))))) ((String "exports")))
(let entry = (get (_library_registry_) ((library_name_key (spec)))) in (if sx_truthy (entry) then (get (entry) ((String "exports"))) else (Dict (Hashtbl.create 0))))
(* register-library *)
and register_library spec exports =

View File

@@ -33,8 +33,10 @@ test.describe('blog SPA', () => {
test('links inside #content get boosted', async ({ page }) => {
await page.goto('/');
await waitReady(page);
// the engine marks a boosted element with data-sx-bound containing "boost"
await expect(page.locator(POSTLINK).first()).toHaveAttribute('data-sx-bound', /boost/, { timeout: 15000 });
// the engine marks a boosted link with the _sxBoundboost JS property
await expect
.poll(() => page.locator(POSTLINK).first().evaluate((a) => !!a._sxBoundboost), { timeout: 15000 })
.toBe(true);
});
test('clicking a link does a fragment swap — no full reload, URL updates', async ({ page }) => {

View File

@@ -643,6 +643,19 @@
loadLibrary(entry.deps[i], loading);
}
// Also eagerly load lazy-deps. Lazy symbol resolution (the _resolve-symbol
// hook) only fires on the VM GLOBAL_GET path, but source-loaded modules run
// their callbacks via the CEK, which raises "Undefined symbol" instead of
// lazy-loading. So when bytecode is unavailable (source fallback), the swap
// post-processing (hs-boot-subtree! / htmx-boot-subtree! in process-elements)
// would fail. Preload them to keep every symbol defined.
var lazyDeps = entry["lazy-deps"] || entry.lazyDeps;
if (lazyDeps) {
for (var li = 0; li < lazyDeps.length; li++) {
loadLibrary(lazyDeps[li], loading);
}
}
// Load entry point itself (boot.sx — not a library, just defines + init)
loadBytecodeFile("sx/" + entry.file) || loadSxFile("sx/" + entry.file.replace(/\.sxbc$/, '.sx'));

View File

@@ -49,7 +49,10 @@
(fn () (host-get (host-get (dom-window) "location") "origin")))
(define
browser-same-origin?
(fn (url) (starts-with? url (browser-location-origin))))
;; A relative URL (no scheme, not protocol-relative "//host") is same-origin
;; by definition; an absolute URL must start with our origin. The old check
;; only did the latter, so it wrongly rejected every relative link ("/x").
(fn (url) (or (starts-with? url (browser-location-origin)) (and (not (string-contains? url "://")) (not (starts-with? url "//"))))))
(define
url-pathname
(fn

View File

@@ -416,7 +416,7 @@
(post-swap t)))
(hoist-head-elements container)
(let
((manifest-el (dom-query-in container "script[data-sx-manifest]")))
((manifest-el (dom-query container "script[data-sx-manifest]")))
(when
manifest-el
(host-call

File diff suppressed because one or more lines are too long

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",[]],["re-9a0de245",[2]],["unix-100664f4",[2]],["sx-836faede",[2,3,4]],["jsoo_runtime-f96b44a8",[2]],["js_of_ocaml-651f6707",[2,6]],["dune__exe__Sx_browser-eb5ef4e3",[2,5,7]],["std_exit-10fb8830",[2]],["start-94d492e1",0]],"generated":(b=>{var
({"link":[["runtime-0db9b496",0],["prelude-d7e4b000",0],["stdlib-23ce0836",[]],["re-9a0de245",[2]],["unix-100664f4",[2]],["sx-8edb1a1d",[2,3,4]],["jsoo_runtime-f96b44a8",[2]],["js_of_ocaml-651f6707",[2,6]],["dune__exe__Sx_browser-4878f9e1",[2,5,7]],["std_exit-10fb8830",[2]],["start-8c705527",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

@@ -476,7 +476,9 @@
library-exports
(fn
(spec)
(get (get *library-registry* (library-name-key spec)) "exports")))
(let
((entry (get *library-registry* (library-name-key spec))))
(if entry (get entry "exports") (dict)))))
(define
register-library

View File

@@ -49,7 +49,10 @@
(fn () (host-get (host-get (dom-window) "location") "origin")))
(define
browser-same-origin?
(fn (url) (starts-with? url (browser-location-origin))))
;; A relative URL (no scheme, not protocol-relative "//host") is same-origin
;; by definition; an absolute URL must start with our origin. The old check
;; only did the latter, so it wrongly rejected every relative link ("/x").
(fn (url) (or (starts-with? url (browser-location-origin)) (and (not (string-contains? url "://")) (not (starts-with? url "//"))))))
(define
url-pathname
(fn

View File

@@ -416,7 +416,7 @@
(post-swap t)))
(hoist-head-elements container)
(let
((manifest-el (dom-query-in container "script[data-sx-manifest]")))
((manifest-el (dom-query container "script[data-sx-manifest]")))
(when
manifest-el
(host-call