host: no flash on relate/unrelate — server-render the picker's first page
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 34s

Relating/removing re-renders the kind's editor (outerHTML); the swapped-in picker's
results <ul> was empty and only filled after its 'load' fetch, so the candidate list
briefly emptied (a visible flash). Render the first page of candidates INTO the
results <ul> server-side (host/blog--relation-editor builds it inline via cons, the
same splice pattern the current-relations list uses), so the re-rendered picker
arrives already populated; the 'load' trigger then re-fetches the same page and
morphs it in place — invisible. No empty state, no flash.

Rendered inline rather than via the ~relate-picker component because component args
are evaluated, so pre-built candidate li-trees can't be spliced through one (they'd
be applied as calls). The component is left in place but unused.

Server-side only — the client engine (orchestration.sxbc, last commit's re-bind fix)
is unchanged. host conformance 278/278 (new: editor server-renders candidates), web
engine suite 8/8, run-picker-check 3/3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-29 20:50:40 +00:00
parent 268e91cd5d
commit 339235a2b5
2 changed files with 47 additions and 7 deletions

View File

@@ -592,7 +592,26 @@
;; current edges read up front (a perform) — NOT inside the quasiquote, where
;; a perform would raise VmSuspended under http-listen.
(let ((spec (host/blog--kind-spec kind))
(current (host/blog-out slug kind)))
(current (host/blog-out slug kind))
;; the results <ul>, server-rendered with the first page of candidates so a
;; re-rendered editor's picker is never briefly empty (the load trigger then
;; re-fetches the same page and morphs it in, invisibly). Built by cons so
;; the candidate li-trees splice in as children (the same pattern the current
;; list uses) — they can't be passed through a component arg (those evaluate).
(results-ul
(let ((cands (take (host/blog--relate-candidates slug "" kind)
host/blog--picker-limit)))
(let ((rows (append
(map (fn (p) (host/blog--picker-item slug p kind)) cands)
(if (= (len cands) host/blog--picker-limit)
(list (host/blog--picker-more slug kind "" host/blog--picker-limit))
(list)))))
(cons (quote ul)
(append
(quasiquote (:id (unquote (str "rp-" kind "-results"))
:class "rp-results"
:style "list-style:none;padding:0;margin:0.5em 0;border:1px solid #ddd"))
rows))))))
(quasiquote
;; #rel-editor-KIND wraps the WHOLE editor (current list + picker) so relate
;; and unrelate can re-render it with one outerHTML swap — keeping the two
@@ -623,12 +642,25 @@
(button :type "submit" "remove")))))
current))
(quote (p :style "opacity:0.7" "None yet."))))
;; The picker is now a reusable, content-addressed SX component
;; (lib/host/sx/relate-picker.sx). render-page expands it server-side on a
;; full load; on a boosted SPA nav the body serialises to the compact
;; (~relate-picker …) and the CLIENT expands it (the component module is
;; loaded content-addressed via the manifest at boot).
(~relate-picker :slug (unquote slug) :kind (unquote kind)))))))
;; The picker, rendered INLINE (not via the ~relate-picker component) so the
;; first page of candidates is server-rendered into the results <ul> — the
;; re-rendered editor shows them immediately, no empty flash. Same declarative
;; SX-htmx form: GET relate-options, innerHTML-swap the results on a debounced
;; "input" and on "load"; sx-retry self-heals a dropped fetch.
(form
:class "relate-picker"
:data-slug (unquote slug)
:data-kind (unquote kind)
:sx-get (unquote (str "/" slug "/relate-options"))
:sx-trigger "input delay:200ms, load"
:sx-target (unquote (str "#rp-" kind "-results"))
:sx-swap "innerHTML"
:sx-retry "exponential:1000:30000"
:style "margin:0"
(input :type "hidden" :name "kind" :value (unquote kind))
(input :type "text" :name "q" :class "rp-filter" :placeholder "filter…"
:autocomplete "off" :style "width:100%;padding:0.4em;box-sizing:border-box")
(unquote results-ul)))))))
;; "Is this post a tag?" toggle — marking a post a tag is just an is-a edge to the
;; "tag" type-post, so it reuses the relate/unrelate routes (no new endpoint).

View File

@@ -281,6 +281,14 @@
(contains? html "input delay:200ms, load")
(contains? html "rp-related-results")))
(list true true true))
;; the editor server-renders the first page of candidates INTO the picker's results
;; <ul>, so a re-rendered editor is never briefly empty (no flash). The candidate row
;; for an existing post appears inside the results ul.
(host-bl-test "editor server-renders the first page of candidates into the picker"
(let ((html (render-page (host/blog--relation-editor "alpha-post" "related"))))
(list (contains? html "id=\"cand-related-") ;; a candidate row is present
(contains? html "Beta Post"))) ;; an unrelated post is offered
(list true true))
;; Paging is server-driven: a full page carries a "load more" sentinel that, when
;; revealed, GETs the next page and replaces itself (outerHTML), preserving q.
(host-bl-test "load-more sentinel: revealed, outerHTML-swap, next offset, preserved q"