stream-colors dict had green/blue keys but data used emerald/violet — all three slots now render with correct Tailwind color classes. Platform: resolveSuspense must not exist on Sx until boot completes, otherwise bootstrap __sxResolve calls it before web stack loads and resolves silently fail. Moved to post-boot setup so all pre-boot resolves queue in __sxPending and drain correctly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
180 lines
7.0 KiB
Plaintext
180 lines
7.0 KiB
Plaintext
;; Streaming & Suspense demo — Phase 6
|
|
;;
|
|
;; This page uses :stream true to enable chunked transfer encoding.
|
|
;; The browser receives the HTML shell immediately with loading skeletons,
|
|
;; then content fills in as each IO resolves at staggered intervals.
|
|
;;
|
|
;; The :data expression is an async generator that yields three chunks
|
|
;; at 1s, 3s, and 5s. Each chunk resolves a different ~shared:pages/suspense slot.
|
|
|
|
;; Color map for stream chunk styling (all string keys for get compatibility)
|
|
(define
|
|
stream-colors
|
|
(dict
|
|
"emerald"
|
|
(dict
|
|
"border"
|
|
"border-emerald-200"
|
|
"bg"
|
|
"bg-emerald-50"
|
|
"dot"
|
|
"bg-emerald-400"
|
|
"title"
|
|
"text-emerald-900"
|
|
"text"
|
|
"text-emerald-800"
|
|
"sub"
|
|
"text-emerald-700"
|
|
"code"
|
|
"bg-emerald-100")
|
|
"amber"
|
|
(dict
|
|
"border"
|
|
"border-amber-200"
|
|
"bg"
|
|
"bg-amber-50"
|
|
"dot"
|
|
"bg-amber-400"
|
|
"title"
|
|
"text-amber-900"
|
|
"text"
|
|
"text-amber-800"
|
|
"sub"
|
|
"text-amber-700"
|
|
"code"
|
|
"bg-amber-100")
|
|
"violet"
|
|
(dict
|
|
"border"
|
|
"border-violet-200"
|
|
"bg"
|
|
"bg-violet-50"
|
|
"dot"
|
|
"bg-violet-400"
|
|
"title"
|
|
"text-violet-900"
|
|
"text"
|
|
"text-violet-800"
|
|
"sub"
|
|
"text-violet-700"
|
|
"code"
|
|
"bg-violet-100")))
|
|
|
|
;; Generic streamed content chunk — rendered once per yield from the
|
|
;; async generator. The :content expression receives different bindings
|
|
;; each time, and the _stream_id determines which ~shared:pages/suspense slot it fills.
|
|
(defcomp ~streaming-demo/chunk (&key (stream-label :as string) (stream-color :as string) (stream-message :as string) (stream-time :as string))
|
|
(let ((colors (get stream-colors stream-color)))
|
|
(div :class (str "rounded-lg border p-5 space-y-3 " (get colors "border") " " (get colors "bg"))
|
|
(div (~tw :tokens "flex items-center gap-2")
|
|
(div :class (str "w-3 h-3 rounded-full " (get colors "dot")))
|
|
(h2 :class (str "text-lg font-semibold " (get colors "title")) stream-label))
|
|
(p :class (get colors "text") stream-message)
|
|
(p :class (str "text-sm " (get colors "sub"))
|
|
"Resolved at: " (code :class (str "px-1 rounded " (get colors "code")) stream-time)))))
|
|
|
|
;; Skeleton placeholder for a stream slot
|
|
(defcomp ~streaming-demo/stream-skeleton ()
|
|
(div (~tw :tokens "rounded-lg border border-stone-200 bg-stone-50 p-5 space-y-3 animate-pulse")
|
|
(div (~tw :tokens "flex items-center gap-2")
|
|
(div (~tw :tokens "w-3 h-3 rounded-full bg-stone-300"))
|
|
(div (~tw :tokens "h-6 bg-stone-200 rounded w-1/3")))
|
|
(div (~tw :tokens "h-4 bg-stone-200 rounded w-2/3"))
|
|
(div (~tw :tokens "h-4 bg-stone-200 rounded w-1/2"))))
|
|
|
|
;; Static layout — takes &rest children where the three suspense slots go.
|
|
(defcomp ~streaming-demo/layout (&rest children)
|
|
(div (~tw :tokens "space-y-8")
|
|
(div (~tw :tokens "border-b border-stone-200 pb-6")
|
|
(h1 (~tw :tokens "text-2xl font-bold text-stone-900") "Streaming & Suspense Demo")
|
|
(p (~tw :tokens "mt-2 text-stone-600")
|
|
"This page uses " (code (~tw :tokens "bg-stone-100 px-1 rounded text-violet-700") ":stream true")
|
|
" in its defpage declaration. The browser receives the page skeleton instantly, "
|
|
"then three IO sources resolve at staggered intervals (1s, 3s, 5s)."))
|
|
|
|
;; Slot: suspense placeholders (or resolved content)
|
|
(div (~tw :tokens "grid gap-4") children)
|
|
|
|
;; Flow diagram
|
|
(div (~tw :tokens "space-y-4")
|
|
(h2 (~tw :tokens "text-lg font-semibold text-stone-800") "Streaming Flow")
|
|
(div (~tw :tokens "grid gap-3")
|
|
(map (fn (item)
|
|
(div (~tw :tokens "flex items-start gap-3 rounded-lg border border-stone-200 bg-white p-4")
|
|
(div (~tw :tokens "flex-shrink-0 w-8 h-8 rounded-full bg-violet-100 flex items-center justify-center text-violet-700 font-bold text-sm")
|
|
(get item "label"))
|
|
(p (~tw :tokens "text-stone-700 text-sm pt-1") (get item "detail"))))
|
|
(list
|
|
{:label "Shell" :detail "HTML shell with three suspense placeholders sent immediately"}
|
|
{:label "Boot" :detail "sx-browser.js loads, renders fallback skeletons"}
|
|
{:label "1s" :detail "Fast API responds — first skeleton replaced with green box"}
|
|
{:label "3s" :detail "Database query completes — second skeleton replaced with blue box"}
|
|
{:label "5s" :detail "ML inference finishes — third skeleton replaced with amber box"}))))
|
|
|
|
;; How it works
|
|
(div (~tw :tokens "rounded-lg border border-violet-200 bg-violet-50 p-5 space-y-3")
|
|
(h2 (~tw :tokens "text-lg font-semibold text-violet-900") "How Multi-Stream Works")
|
|
(ol (~tw :tokens "list-decimal list-inside text-violet-800 space-y-2 text-sm")
|
|
(li "Server evaluates " (code ":data") " — gets an " (em "async generator"))
|
|
(li "HTML shell with three " (code "~shared:pages/suspense") " placeholders sent immediately")
|
|
(li "Generator yields first chunk after 1s — server sends " (code "__sxResolve(\"stream-fast\", ...)"))
|
|
(li "Generator yields second chunk after 3s — " (code "__sxResolve(\"stream-medium\", ...)"))
|
|
(li "Generator yields third chunk after 5s — " (code "__sxResolve(\"stream-slow\", ...)"))
|
|
(li "Each resolve replaces its skeleton independently")))
|
|
|
|
;; Technical details
|
|
(div (~tw :tokens "rounded-lg border border-stone-200 bg-stone-50 p-4 text-sm space-y-2")
|
|
(p (~tw :tokens "font-semibold text-stone-800") "Implementation details")
|
|
(ul (~tw :tokens "list-disc list-inside text-stone-600 space-y-1")
|
|
(li (code "defpage :stream true") " — opts the page into chunked transfer encoding")
|
|
(li (code ":data") " helper is an async generator — each " (code "yield") " resolves a different suspense slot")
|
|
(li "Each yield includes " (code "_stream_id") " matching a " (code "~shared:pages/suspense :id") " in the shell")
|
|
(li (code ":content") " expression is re-evaluated with each yield's bindings")
|
|
(li "Headers stream concurrently — independent of the data generator")
|
|
(li "Future: SSE/WebSocket for re-resolving slots after initial page load")))))
|
|
|
|
(define
|
|
streaming-demo-data
|
|
(fn
|
|
()
|
|
(list
|
|
(dict
|
|
"stream-id"
|
|
"stream-fast"
|
|
"delay"
|
|
1000
|
|
"stream_label"
|
|
"Fast source"
|
|
"stream_color"
|
|
"emerald"
|
|
"stream_message"
|
|
"Resolved in ~1s (async IO)"
|
|
"stream_time"
|
|
"~1s")
|
|
(dict
|
|
"stream-id"
|
|
"stream-medium"
|
|
"delay"
|
|
3000
|
|
"stream_label"
|
|
"Medium source"
|
|
"stream_color"
|
|
"amber"
|
|
"stream_message"
|
|
"Resolved in ~3s (async IO)"
|
|
"stream_time"
|
|
"~3s")
|
|
(dict
|
|
"stream-id"
|
|
"stream-slow"
|
|
"delay"
|
|
5000
|
|
"stream_label"
|
|
"Slow source"
|
|
"stream_color"
|
|
"violet"
|
|
"stream_message"
|
|
"Resolved in ~5s (async IO)"
|
|
"stream_time"
|
|
"~5s"))))
|