Fix navigation: outerHTML swap, island markers, host handle equality, dispose

Navigation pipeline now works end-to-end:
- outerHTML swap uses dom-replace-child instead of morph-node (morph has
  a CEK continuation issue with nested for-each that needs separate fix)
- swap-dom-nodes returns the new element for outerHTML so post-swap
  hydrates the correct (new) DOM, not the detached old element
- sx-render uses marker mode: islands rendered as empty span[data-sx-island]
  markers, hydrated by post-swap. Prevents duplicate content from island
  body expansion + SX response nav rows.
- dispose-island (singular) called on old island before morph, not just
  dispose-islands-in (which only disposes sub-islands)

OCaml runtime:
- safe_eq: Dict equality checks __host_handle for DOM node identity
  (js_to_value creates new Dict wrappers per call, breaking physical ==)
- contains?: same host handle check
- to_string: trampoline thunks (fixes <thunk> display)
- as_number: trampoline thunks (fixes arithmetic on leaked thunks)

DOM platform:
- dom-remove, dom-attr-list (name/value pairs), dom-child-list (SX list),
  dom-is-active-element?, dom-is-input-element?, dom-is-child-of?, dom-on

All 5 reactive-nav Playwright tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 13:19:29 +00:00
parent 07bbcaf1bb
commit eacde62806
10 changed files with 2352 additions and 30 deletions

View File

@@ -151,7 +151,14 @@
(and (starts-with? name "~")
(env-has? env name)
(island? (env-get env name)))
(render-dom-island (env-get env name) args env ns)
;; In marker mode (sx-render for swap), create empty marker span
;; that post-swap will hydrate. Otherwise, full render.
(if (scope-peek "sx-render-markers")
(let ((island (env-get env name))
(marker (dom-create-element "span" nil)))
(dom-set-attr marker "data-sx-island" (component-name island))
marker)
(render-dom-island (env-get env name) args env ns))
;; Component (~name)
(starts-with? name "~")

View File

@@ -375,8 +375,10 @@
(dom-has-attr? new-node "data-sx-island")
(not (= (dom-get-attr old-node "data-sx-island")
(dom-get-attr new-node "data-sx-island"))))
(dispose-islands-in old-node)
(clear-processed! old-node "island-hydrated"))
;; Dispose the old island itself (not just sub-islands)
;; to tear down reactive effects before morphing content
(dispose-island old-node)
(dispose-islands-in old-node))
(sync-attrs old-node new-node)
;; Skip morphing focused input to preserve user's in-progress edits
(when (not (and (dom-is-active-element? old-node)
@@ -602,19 +604,22 @@
(morph-children target wrapper)))
"outerHTML"
(let ((parent (dom-parent target)))
(let ((parent (dom-parent target))
(new-el (dom-clone new-nodes)))
(if (dom-is-fragment? new-nodes)
;; Fragment — morph first child, insert rest
;; Fragment — replace target with fragment children
(let ((fc (dom-first-child new-nodes)))
(if fc
(do
(morph-node target fc)
;; Insert remaining siblings after morphed element
(set! new-el (dom-clone fc))
(dom-replace-child parent new-el target)
(let ((sib (dom-next-sibling fc)))
(insert-remaining-siblings parent target sib)))
(insert-remaining-siblings parent new-el sib)))
(dom-remove-child parent target)))
(morph-node target new-nodes))
parent)
;; Element — replace target with new element
(dom-replace-child parent new-el target))
;; Return the new element so post-swap can hydrate it
new-el)
"afterend"
(dom-insert-after target new-nodes)

View File

@@ -589,13 +589,19 @@
(define sx-render
(fn (text)
;; Parse SX text and render to a DOM fragment.
;; Islands are rendered as empty markers (span with data-sx-island)
;; — post-swap will hydrate them. This matches the server's aser mode
;; where island calls are serialized without expansion.
(let ((doc (host-global "document"))
(frag (host-call doc "createDocumentFragment"))
(exprs (sx-parse text)))
;; Push marker mode: render-dom-island creates markers, not full renders
(scope-push! "sx-render-markers" true)
(for-each (fn (expr)
(let ((result (render-to-dom expr (get-render-env nil) nil)))
(when result (dom-append frag result))))
exprs)
(scope-pop! "sx-render-markers")
frag)))
(define sx-hydrate

View File

@@ -296,11 +296,11 @@
;; Hydrated islands are preserved — the morph algorithm
;; keeps their live signals and only morphs their lakes.
(dispose-islands-in target)
;; Swap
;; Swap — swap-dom-nodes returns the new element for outerHTML
(with-transition use-transition
(fn ()
(swap-dom-nodes target content swap-style)
(post-swap target)))))))))))
(let ((swap-result (swap-dom-nodes target content swap-style)))
(post-swap (or swap-result target))))))))))))
(define handle-html-response :effects [mutation io]