Files
rose-ash/shared/static/wasm/sx/boot.sx
giles c923a34fa8 Fix WASM browser click handlers: 8 bugs, 50 new VM tests
The sx-get links were doing full page refreshes because click handlers
never attached. Root causes: VM frame management bug, missing primitives,
CEK/VM type dispatch mismatch, and silent error swallowing.

Fixes:
- VM frame exhaustion: frames <- [] now properly pops to rest_frames
- length primitive: add alias for len in OCaml primitives
- call_sx_fn: use sx_call directly instead of eval_expr (CEK checks
  for type "lambda" but VmClosure reports "function")
- Boot error surfacing: Sx.init() now has try/catch + failure summary
- Callback error surfacing: catch-all handler for non-Eval_error exceptions
- Silent JIT failures: log before CEK fallback instead of swallowing
- vm→env sync: loadModule now calls sync_vm_to_env()
- sx_build_bytecode MCP tool added for bytecode compilation

Tests: 50 new tests across test-vm.sx and test-vm-primitives.sx covering
nested VM calls, frame integrity, CEK bridge, primitive availability,
cross-module symbol resolution, and callback dispatch.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 00:37:21 +00:00

435 lines
13 KiB
Plaintext

(define
HEAD_HOIST_SELECTOR
"meta, title, link[rel='canonical'], script[type='application/ld+json']")
(define
hoist-head-elements-full
:effects (mutation io)
(fn
(root)
(let
((els (dom-query-all root HEAD_HOIST_SELECTOR)))
(for-each
(fn
(el)
(let
((tag (lower (dom-tag-name el))))
(cond
(= tag "title")
(do
(set-document-title (dom-text-content el))
(dom-remove-child (dom-parent el) el))
(= tag "meta")
(do
(let
((name (dom-get-attr el "name"))
(prop (dom-get-attr el "property")))
(when
name
(remove-head-element (str "meta[name=\"" name "\"]")))
(when
prop
(remove-head-element (str "meta[property=\"" prop "\"]"))))
(dom-remove-child (dom-parent el) el)
(dom-append-to-head el))
(and (= tag "link") (= (dom-get-attr el "rel") "canonical"))
(do
(remove-head-element "link[rel=\"canonical\"]")
(dom-remove-child (dom-parent el) el)
(dom-append-to-head el))
:else (do
(dom-remove-child (dom-parent el) el)
(dom-append-to-head el)))))
els))))
(define
sx-mount
:effects (mutation io)
(fn
(target (source :as string) (extra-env :as dict))
(let
((el (resolve-mount-target target)))
(when
el
(when
(empty? (dom-child-list el))
(let
((node (sx-render-with-env source extra-env)))
(dom-set-text-content el "")
(dom-append el node)
(hoist-head-elements-full el)))
(process-elements el)
(sx-hydrate-elements el)
(sx-hydrate-islands el)
(run-post-render-hooks)))))
(define
resolve-suspense
:effects (mutation io)
(fn
((id :as string) (sx :as string))
(process-sx-scripts nil)
(let
((el (dom-query (str "[data-suspense=\"" id "\"]"))))
(if
el
(do
(let
((exprs (parse sx)) (env (get-render-env nil)))
(dom-set-text-content el "")
(for-each
(fn (expr) (dom-append el (render-to-dom expr env nil)))
exprs)
(process-elements el)
(sx-hydrate-elements el)
(sx-hydrate-islands el)
(run-post-render-hooks)
(dom-dispatch el "sx:resolved" {:id id})))
(log-warn (str "resolveSuspense: no element for id=" id))))))
(define
sx-hydrate-elements
:effects (mutation io)
(fn
(root)
(let
((els (dom-query-all (or root (dom-body)) "[data-sx]")))
(for-each
(fn
(el)
(when
(not (is-processed? el "hydrated"))
(mark-processed! el "hydrated")
(sx-update-element el nil)))
els))))
(define
sx-update-element
:effects (mutation io)
(fn
(el new-env)
(let
((target (resolve-mount-target el)))
(when
target
(let
((source (dom-get-attr target "data-sx")))
(when
source
(let
((base-env (parse-env-attr target))
(env (merge-envs base-env new-env)))
(let
((node (sx-render-with-env source env)))
(dom-set-text-content target "")
(dom-append target node)
(when new-env (store-env-attr target base-env new-env))))))))))
(define
sx-render-component
:effects (mutation io)
(fn
((name :as string) (kwargs :as dict) (extra-env :as dict))
(let
((full-name (if (starts-with? name "~") name (str "~" name))))
(let
((env (get-render-env extra-env)) (comp (env-get env full-name)))
(if
(not (component? comp))
(error (str "Unknown component: " full-name))
(let
((call-expr (list (make-symbol full-name))))
(for-each
(fn
((k :as string))
(append! call-expr (make-keyword (to-kebab k)))
(append! call-expr (dict-get kwargs k)))
(keys kwargs))
(render-to-dom call-expr env nil)))))))
(define
process-sx-scripts
:effects (mutation io)
(fn
(root)
(let
((scripts (query-sx-scripts root)))
(for-each
(fn
(s)
(when
(not (is-processed? s "script"))
(mark-processed! s "script")
(let
((text (dom-text-content s)))
(cond
(dom-has-attr? s "data-components")
(process-component-script s text)
(or (nil? text) (empty? (trim text)))
nil
(dom-has-attr? s "data-init")
(let
((exprs (sx-parse text)))
(for-each (fn (expr) (cek-eval expr)) exprs))
(dom-has-attr? s "data-mount")
(let
((mount-sel (dom-get-attr s "data-mount"))
(target (dom-query mount-sel)))
(when target (sx-mount target text nil)))
:else (sx-load-components text)))))
scripts))))
(define
process-component-script
:effects (mutation io)
(fn
(script (text :as string))
(let
((hash (dom-get-attr script "data-hash")))
(if
(nil? hash)
(when
(and text (not (empty? (trim text))))
(sx-load-components text))
(let
((has-inline (and text (not (empty? (trim text))))))
(let
((cached-hash (local-storage-get "sx-components-hash")))
(if
(= cached-hash hash)
(if
has-inline
(do
(local-storage-set "sx-components-hash" hash)
(local-storage-set "sx-components-src" text)
(sx-load-components text)
(log-info "components: downloaded (cookie stale)"))
(let
((cached (local-storage-get "sx-components-src")))
(if
cached
(do
(sx-load-components cached)
(log-info (str "components: cached (" hash ")")))
(do (clear-sx-comp-cookie) (browser-reload)))))
(if
has-inline
(do
(local-storage-set "sx-components-hash" hash)
(local-storage-set "sx-components-src" text)
(sx-load-components text)
(log-info (str "components: downloaded (" hash ")")))
(do
(local-storage-remove "sx-components-hash")
(local-storage-remove "sx-components-src")
(clear-sx-comp-cookie)
(browser-reload)))))
(set-sx-comp-cookie hash))))))
(define _page-routes (list))
(define
process-page-scripts
:effects (mutation io)
(fn
()
(let
((scripts (query-page-scripts)))
(log-info (str "pages: found " (len scripts) " script tags"))
(for-each
(fn
(s)
(when
(not (is-processed? s "pages"))
(mark-processed! s "pages")
(let
((text (dom-text-content s)))
(log-info
(str "pages: script text length=" (if text (len text) 0)))
(if
(and text (not (empty? (trim text))))
(let
((pages (parse text)))
(log-info (str "pages: parsed " (len pages) " entries"))
(for-each
(fn
((page :as dict))
(append! _page-routes (merge page {:parsed (parse-route-pattern (get page "path"))})))
pages))
(log-warn "pages: script tag is empty")))))
scripts)
(log-info (str "pages: " (len _page-routes) " routes loaded")))))
(define
sx-hydrate-islands
:effects (mutation io)
(fn
(root)
(let
((els (dom-query-all (or root (dom-body)) "[data-sx-island]")))
(log-info
(str
"sx-hydrate-islands: "
(len els)
" island(s) in "
(if root "subtree" "document")))
(for-each
(fn
(el)
(if
(is-processed? el "island-hydrated")
(log-info
(str
" skip (already hydrated): "
(dom-get-attr el "data-sx-island")))
(do
(log-info
(str " hydrating: " (dom-get-attr el "data-sx-island")))
(mark-processed! el "island-hydrated")
(hydrate-island el))))
els))))
(define
hydrate-island
:effects (mutation io)
(fn
(el)
(let
((name (dom-get-attr el "data-sx-island"))
(state-sx (or (dom-get-attr el "data-sx-state") "{}")))
(let
((comp-name (str "~" name)) (env (get-render-env nil)))
(let
((comp (env-get env comp-name)))
(if
(not (or (component? comp) (island? comp)))
(log-warn (str "hydrate-island: unknown island " comp-name))
(let
((kwargs (or (first (sx-parse state-sx)) {}))
(disposers (list))
(local (env-merge (component-closure comp) env)))
(for-each
(fn
((p :as string))
(env-bind!
local
p
(if (dict-has? kwargs p) (dict-get kwargs p) nil)))
(component-params comp))
(let
((body-dom (cek-try (fn () (with-island-scope (fn (disposable) (append! disposers disposable)) (fn () (render-to-dom (component-body comp) local nil)))) (fn (err) (log-warn (str "hydrate-island FAILED: " comp-name " — " err)) (let ((error-el (dom-create-element "div" nil))) (dom-set-attr error-el "class" "sx-island-error") (dom-set-attr error-el "style" "padding:8px;margin:4px 0;border:1px solid #ef4444;border-radius:4px;background:#fef2f2;color:#b91c1c;font-family:monospace;font-size:12px;white-space:pre-wrap") (dom-set-text-content error-el (str "Island error: " comp-name "\n" err)) error-el)))))
(dom-set-text-content el "")
(dom-append el body-dom)
(dom-set-data el "sx-disposers" disposers)
(process-elements el)
(log-info
(str
"hydrated island: "
comp-name
" ("
(len disposers)
" disposers)"))))))))))
(define
dispose-island
:effects (mutation io)
(fn
(el)
(let
((disposers (dom-get-data el "sx-disposers")))
(when
disposers
(for-each
(fn ((d :as lambda)) (when (callable? d) (d)))
disposers)
(dom-set-data el "sx-disposers" nil)))
(clear-processed! el "island-hydrated")))
(define
dispose-islands-in
:effects (mutation io)
(fn
(root)
(when
root
(let
((islands (dom-query-all root "[data-sx-island]")))
(when
(and islands (not (empty? islands)))
(let
((to-dispose (filter (fn (el) (not (is-processed? el "island-hydrated"))) islands)))
(when
(not (empty? to-dispose))
(log-info (str "disposing " (len to-dispose) " island(s)"))
(for-each dispose-island to-dispose))))))))
(define
force-dispose-islands-in
:effects (mutation io)
(fn
(root)
(when
root
(let
((islands (dom-query-all root "[data-sx-island]")))
(when
(and islands (not (empty? islands)))
(log-info (str "force-disposing " (len islands) " island(s)"))
(for-each dispose-island islands))))))
(define *pre-render-hooks* (list))
(define *post-render-hooks* (list))
(define
register-pre-render-hook
:effects (mutation)
(fn ((hook-fn :as lambda)) (append! *pre-render-hooks* hook-fn)))
(define
register-post-render-hook
:effects (mutation)
(fn ((hook-fn :as lambda)) (append! *post-render-hooks* hook-fn)))
(define
run-pre-render-hooks
:effects (mutation io)
(fn () (for-each (fn (hook) (cek-call hook nil)) *pre-render-hooks*)))
(define
run-post-render-hooks
:effects (mutation io)
(fn
()
(log-info
(str "run-post-render-hooks: " (len *post-render-hooks*) " hooks"))
(for-each
(fn
(hook)
(log-info
(str
" hook type: "
(type-of hook)
" callable: "
(callable? hook)
" lambda: "
(lambda? hook)))
(cek-call hook nil))
*post-render-hooks*)))
(define
boot-init
:effects (mutation io)
(fn
()
(do
(log-info (str "sx-browser " SX_VERSION))
(init-css-tracking)
(process-page-scripts)
(process-sx-scripts nil)
(sx-hydrate-elements nil)
(sx-hydrate-islands nil)
(run-post-render-hooks)
(process-elements nil)
(dom-listen (dom-window) "popstate" (fn (e) (handle-popstate 0))))))