Files
rose-ash/web/boot.sx
giles b53a0fabea Inline test runner: 5/5 temperature converter tests passing
Fixed three fundamental issues:
1. cek-try arg passing: handler was called with raw string instead of
   (List [String msg]), causing "lambda expects 1 args, got N" errors
2. Silent island hydration failures: hydrate-island now wraps body
   render in cek-try, displaying red error box with stack trace instead
   of empty div. No more silent failures.
3. swap! thunk leak: apply result wasn't trampolined, storing thunks
   as signal values instead of evaluated results

Also fixed: assert= uses = instead of equal? for value comparison,
assert-signal-value uses deref instead of signal-value, HTML entity
decoding in script tag test source via host-call replaceAll.

Temperature converter demo page now shows live test results:
  ✓ initial celsius is 20
  ✓ computed fahrenheit = celsius * 1.8 + 32
  ✓ +5 increments celsius
  ✓ fahrenheit updates on celsius change
  ✓ multiple clicks accumulate

1116/1116 OCaml tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 01:51:30 +00:00

46 lines
9.4 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) (eval-expr expr (env-extend (dict)))) 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))))))