Merge branch 'worktree-iso-phase-4' into macros

This commit is contained in:
2026-03-07 17:34:14 +00:00
15 changed files with 586 additions and 38 deletions

View File

@@ -69,3 +69,8 @@
:params (spec-name)
:returns "dict"
:service "sx")
(define-page-helper "streaming-demo-data"
:params ()
:returns "dict"
:service "sx")

View File

@@ -122,7 +122,8 @@
(dict :label "Bundle Analyzer" :href "/isomorphism/bundle-analyzer")
(dict :label "Routing Analyzer" :href "/isomorphism/routing-analyzer")
(dict :label "Data Test" :href "/isomorphism/data-test")
(dict :label "Async IO" :href "/isomorphism/async-io")))
(dict :label "Async IO" :href "/isomorphism/async-io")
(dict :label "Streaming" :href "/isomorphism/streaming")))
(define plans-nav-items (list
(dict :label "Status" :href "/plans/status"

View File

@@ -1386,12 +1386,12 @@
(p :class "text-sm text-stone-600" "OAuth-based sharing to Facebook, Instagram, Threads, Twitter/X, LinkedIn, and Mastodon via the account service. No models, blueprints, or platform clients created.")
(p :class "text-sm text-stone-500 mt-1" "Remaining: SocialConnection model, social_crypto.py, platform OAuth clients (6), account/bp/social/ blueprint, share button fragment."))
(div :class "rounded border border-stone-200 bg-stone-50 p-4"
(div :class "rounded border border-green-200 bg-green-50 p-4"
(div :class "flex items-center gap-2 mb-1"
(span :class "inline-block px-2 py-0.5 rounded text-xs font-bold bg-stone-500 text-white uppercase" "Not Started")
(span :class "inline-block px-2 py-0.5 rounded text-xs font-bold bg-green-600 text-white uppercase" "Complete")
(a :href "/isomorphism/" :class "font-semibold text-stone-800 underline" "Isomorphic Phase 6: Streaming & Suspense"))
(p :class "text-sm text-stone-600" "Server streams partially-evaluated SX as IO resolves. Client renders available subtrees immediately, fills in suspended parts. Requires async-aware delimited continuations for suspension.")
(p :class "text-sm text-stone-500 mt-1" "Depends on: Phase 5 (IO proxy), continuations spec."))
(p :class "text-sm text-stone-600" "Server streams partially-evaluated SX as IO resolves. ~suspense component renders fallbacks, inline resolution scripts fill in content. Concurrent IO via asyncio, chunked transfer encoding.")
(p :class "text-sm text-stone-500 mt-1" "Demo: " (a :href "/isomorphism/streaming" "/isomorphism/streaming")))
(div :class "rounded border border-stone-200 bg-stone-50 p-4"
(div :class "flex items-center gap-2 mb-1"
@@ -1882,39 +1882,68 @@
(~doc-section :title "Phase 6: Streaming & Suspense" :id "phase-6"
(div :class "rounded border border-violet-200 bg-violet-50 p-4 mb-4"
(p :class "text-violet-900 font-medium" "What it enables")
(p :class "text-violet-800" "Server streams partially-evaluated SX as IO resolves. Client renders available subtrees immediately, fills in suspended parts. Like React Suspense but built on delimited continuations."))
(div :class "rounded border border-green-200 bg-green-50 p-4 mb-4"
(p :class "text-green-900 font-medium" "Status: Implemented")
(p :class "text-green-800" "Server streams partially-evaluated SX as IO resolves. Client renders available subtrees immediately with loading skeletons, fills in suspended parts as data arrives."))
(div :class "rounded border border-amber-200 bg-amber-50 p-3 mb-4"
(p :class "text-amber-800 text-sm" (strong "Prerequisite: ") "Async-aware delimited continuations. The client solved IO suspension via JavaScript Promises (Phase 5), but the server needs continuations to suspend mid-evaluation when IO is encountered during streaming. Python's evaluator must capture the continuation at an IO call, emit a placeholder, schedule the IO, and resume the continuation when the result arrives."))
(~doc-subsection :title "What was built"
(ul :class "list-disc pl-5 text-stone-700 space-y-1"
(li (code "~suspense") " component — renders fallback content with a stable DOM ID, replaced when resolution arrives")
(li (code "defpage :stream true") " — opts a page into streaming response mode")
(li (code "defpage :fallback expr") " — custom loading skeleton for streaming pages")
(li (code "execute_page_streaming()") " — Quart async generator response that yields HTML chunks")
(li (code "sx_page_streaming_parts()") " — splits the HTML shell into streamable parts")
(li (code "Sx.resolveSuspense(id, sx)") " — client-side function to replace suspense placeholders")
(li (code "window.__sxResolve") " bootstrap — queues resolutions that arrive before sx.js loads")
(li "Concurrent IO: data eval + header eval run in parallel via " (code "asyncio.create_task"))
(li "Completion-order streaming: whichever IO finishes first gets sent first via " (code "asyncio.wait(FIRST_COMPLETED)"))))
(~doc-subsection :title "Approach"
(~doc-subsection :title "Architecture"
(div :class "space-y-4"
(div
(h4 :class "font-semibold text-stone-700" "1. Continuation-based suspension")
(p "When _aser encounters IO during slot evaluation, emit a placeholder with a suspension ID, schedule async resolution:")
(~doc-code :code (highlight "(~suspense :id \"placeholder-123\"\n :fallback (div \"Loading...\"))" "lisp")))
(h4 :class "font-semibold text-stone-700" "1. Suspense component")
(p "When streaming, the server renders the page with " (code "~suspense") " placeholders instead of awaiting IO:")
(~doc-code :code (highlight "(~app-body\n :header-rows (~suspense :id \"stream-headers\"\n :fallback (div :class \"h-12 bg-stone-200 animate-pulse\"))\n :content (~suspense :id \"stream-content\"\n :fallback (div :class \"p-8 animate-pulse\" ...)))" "lisp")))
(div
(h4 :class "font-semibold text-stone-700" "2. Chunked transfer")
(p "Quart async generator responses:")
(ul :class "list-disc pl-5 text-stone-700 space-y-1"
(li "First chunk: HTML shell + synchronous content + placeholders")
(li "Subsequent chunks: <script> tags replacing placeholders with resolved content")))
(p "Quart async generator response yields chunks in order:")
(ol :class "list-decimal pl-5 text-stone-700 space-y-1"
(li "HTML shell + CSS + component defs + page registry + suspense page SX + scripts (immediate)")
(li "Resolution " (code "<script>") " tags as each IO completes")
(li "Closing " (code "</body></html>"))))
(div
(h4 :class "font-semibold text-stone-700" "3. Client suspension rendering")
(p "~suspense component renders fallback, listens for resolution via inline script or SSE (existing SSE infrastructure in orchestration.sx)."))
(h4 :class "font-semibold text-stone-700" "3. Client resolution")
(p "Each resolution chunk is an inline script:")
(~doc-code :code (highlight "<script>\n window.__sxResolve(\"stream-content\",\n \"(~article :title \\\"Hello\\\")\")\n</script>" "html"))
(p "The client parses the SX, renders to DOM, and replaces the suspense placeholder's children."))
(div
(h4 :class "font-semibold text-stone-700" "4. Priority-based IO")
(p "Above-fold content resolves first. All IO starts concurrently (asyncio.create_task), results flushed in priority order."))))
(h4 :class "font-semibold text-stone-700" "4. Concurrent IO")
(p "Data evaluation and header construction run in parallel. " (code "asyncio.wait(FIRST_COMPLETED)") " yields resolution chunks in whatever order IO completes — no artificial sequencing."))))
(div :class "rounded border border-amber-200 bg-amber-50 p-3 mt-2"
(p :class "text-amber-800 text-sm" (strong "Depends on: ") "Phase 5 (IO proxy for client rendering), async-aware delimited continuations (for server-side suspension), Phase 2 (IO analysis for priority).")))
(~doc-subsection :title "Continuation foundation"
(p "Delimited continuations (" (code "reset") "/" (code "shift") ") are implemented in the Python evaluator (async_eval.py lines 586-624) and available as special forms. Phase 6 uses the simpler pattern of concurrent IO + completion-order streaming, but the continuation machinery is in place for Phase 7's more sophisticated evaluation-level suspension."))
(~doc-subsection :title "Files"
(ul :class "list-disc pl-5 text-stone-700 space-y-1 font-mono text-sm"
(li "shared/sx/templates/pages.sx — ~suspense component definition")
(li "shared/sx/types.py — PageDef.stream, PageDef.fallback_expr fields")
(li "shared/sx/evaluator.py — defpage :stream/:fallback parsing")
(li "shared/sx/pages.py — execute_page_streaming(), streaming route mounting")
(li "shared/sx/helpers.py — sx_page_streaming_parts(), sx_streaming_resolve_script()")
(li "shared/static/scripts/sx.js — Sx.resolveSuspense(), __sxPending queue, __sxResolve bootstrap")
(li "shared/sx/async_eval.py — reset/shift special forms (continuation foundation)")))
(~doc-subsection :title "Verification"
(ul :class "list-disc pl-5 text-stone-700 space-y-1"
(li "Navigate to " (a :href "/isomorphism/streaming" "/isomorphism/streaming") " — the streaming demo page")
(li "The page skeleton appears instantly (loading skeletons)")
(li "After ~1.5 seconds, the content fills in (streamed from server)")
(li "Open Network tab — observe chunked transfer encoding on the document response")
(li "The document response should show multiple chunks arriving over time"))))
;; -----------------------------------------------------------------------
;; Phase 7

60
sx/sx/streaming-demo.sx Normal file
View File

@@ -0,0 +1,60 @@
;; 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 the content fills in when the (deliberately slow) data resolves.
;;
;; The :data expression simulates 1.5s IO delay. Without streaming, the
;; browser would wait the full 1.5s before seeing anything. With streaming,
;; the page skeleton appears instantly.
(defcomp ~streaming-demo-content (&key streamed-at message items)
(div :class "space-y-8"
(div :class "border-b border-stone-200 pb-6"
(h1 :class "text-2xl font-bold text-stone-900" "Streaming & Suspense Demo")
(p :class "mt-2 text-stone-600"
"This page uses " (code :class "bg-stone-100 px-1 rounded text-violet-700" ":stream true")
" in its defpage declaration. The browser receives the page skeleton instantly, "
"then content fills in as IO resolves."))
;; Timestamp proves this was streamed
(div :class "rounded-lg border border-green-200 bg-green-50 p-5 space-y-3"
(h2 :class "text-lg font-semibold text-green-900" "Streamed Content")
(p :class "text-green-800" message)
(p :class "text-green-700 text-sm"
"Data resolved at: " (code :class "bg-green-100 px-1 rounded" streamed-at))
(p :class "text-green-700 text-sm"
"This content arrived via a " (code :class "bg-green-100 px-1 rounded" "<script>__sxResolve(...)</script>")
" chunk streamed after the initial HTML shell."))
;; Flow diagram
(div :class "space-y-4"
(h2 :class "text-lg font-semibold text-stone-800" "Streaming Flow")
(div :class "grid gap-3"
(map (fn (item)
(div :class "flex items-start gap-3 rounded-lg border border-stone-200 bg-white p-4"
(div :class "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 :class "text-stone-700 text-sm pt-1" (get item "detail"))))
items)))
;; How it works
(div :class "rounded-lg border border-blue-200 bg-blue-50 p-5 space-y-3"
(h2 :class "text-lg font-semibold text-blue-900" "How Streaming Works")
(ol :class "list-decimal list-inside text-blue-800 space-y-2 text-sm"
(li "Server starts data fetch and header fetch " (em "concurrently"))
(li "HTML shell with " (code "~suspense") " placeholders is sent immediately")
(li "Browser loads sx-browser.js, renders the page with loading skeletons")
(li "Data IO completes — server sends " (code "<script>__sxResolve(\"stream-content\", ...)</script>"))
(li "sx.js calls " (code "Sx.resolveSuspense()") " — replaces skeleton with real content")
(li "Header IO completes — same process for header area")))
;; Technical details
(div :class "rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm space-y-2"
(p :class "font-semibold text-amber-800" "Implementation details")
(ul :class "list-disc list-inside text-amber-700 space-y-1"
(li (code "defpage :stream true") " — opts the page into streaming response")
(li (code "~suspense :id \"...\" :fallback (...)") " — renders loading skeleton until resolved")
(li "Quart async generator response — yields chunks as they become available")
(li "Resolution via " (code "__sxResolve(id, sx)") " inline scripts in the stream")
(li "Falls back to standard (non-streaming) response for SX/HTMX requests")))))

View File

@@ -458,6 +458,26 @@
:selected "Async IO")
:content (~async-io-demo-content))
(defpage streaming-demo
:path "/isomorphism/streaming"
:auth :public
:stream true
:layout (:sx-section
:section "Isomorphism"
:sub-label "Isomorphism"
:sub-href "/isomorphism/"
:sub-nav (~section-nav :items isomorphism-nav-items :current "Streaming")
:selected "Streaming")
:fallback (div :class "p-8 space-y-4 animate-pulse"
(div :class "h-8 bg-stone-200 rounded w-1/3")
(div :class "h-4 bg-stone-200 rounded w-2/3")
(div :class "h-64 bg-stone-200 rounded"))
:data (streaming-demo-data)
:content (~streaming-demo-content
:streamed-at streamed-at
:message message
:items items))
;; Wildcard must come AFTER specific routes (first-match routing)
(defpage isomorphism-page
:path "/isomorphism/<slug>"

View File

@@ -26,6 +26,7 @@ def _register_sx_helpers() -> None:
"data-test-data": _data_test_data,
"run-spec-tests": _run_spec_tests,
"run-modular-tests": _run_modular_tests,
"streaming-demo-data": _streaming_demo_data,
})
@@ -791,3 +792,21 @@ def _data_test_data() -> dict:
"phase": "Phase 4 — Client Async & IO Bridge",
"transport": "SX wire format (text/sx)",
}
async def _streaming_demo_data() -> dict:
"""Simulate slow IO for streaming demo — 1.5s delay."""
import asyncio
await asyncio.sleep(1.5)
from datetime import datetime, timezone
return {
"streamed-at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
"message": "This content was streamed after a 1.5 second delay.",
"items": [
{"label": "Shell", "detail": "HTML shell with suspense placeholders sent immediately"},
{"label": "Bootstrap", "detail": "sx-browser.js loads, renders fallback skeletons"},
{"label": "IO Start", "detail": "Data fetch and header fetch run concurrently"},
{"label": "Resolve", "detail": "As each IO completes, <script> chunk replaces placeholder"},
{"label": "Done", "detail": "Page fully rendered — all suspense resolved"},
],
}