diff --git a/shared/static/scripts/sx-browser.js b/shared/static/scripts/sx-browser.js
index 239cfa3..4488529 100644
--- a/shared/static/scripts/sx-browser.js
+++ b/shared/static/scripts/sx-browser.js
@@ -14,7 +14,7 @@
// =========================================================================
var NIL = Object.freeze({ _nil: true, toString: function() { return "nil"; } });
- var SX_VERSION = "2026-03-07T09:51:42Z";
+ var SX_VERSION = "2026-03-07T17:30:45Z";
function isNil(x) { return x === NIL || x === null || x === undefined; }
function isSxTruthy(x) { return x !== false && !isNil(x); }
@@ -2367,6 +2367,21 @@ allKf = concat(allKf, styleValueKeyframes_(sv)); } }
processElements(el);
return sxHydrateElements(el);
})() : NIL);
+})(); };
+
+ // resolve-suspense
+ var resolveSuspense = function(id, sx) { return (function() {
+ var el = domQuery((String("[data-suspense=\"") + String(id) + String("\"]")));
+ return (isSxTruthy(el) ? (function() {
+ var ast = parse(sx);
+ var env = getRenderEnv(NIL);
+ var node = renderToDom(ast, env, NIL);
+ domSetTextContent(el, "");
+ domAppend(el, node);
+ processElements(el);
+ sxHydrateElements(el);
+ return domDispatch(el, "sx:resolved", {"id": id});
+})() : logWarn((String("resolveSuspense: no element for id=") + String(id))));
})(); };
// sx-hydrate-elements
@@ -4492,6 +4507,7 @@ callExpr.push(dictGet(kwargs, k)); } }
update: typeof sxUpdateElement === "function" ? sxUpdateElement : null,
renderComponent: typeof sxRenderComponent === "function" ? sxRenderComponent : null,
getEnv: function() { return componentEnv; },
+ resolveSuspense: typeof resolveSuspense === "function" ? resolveSuspense : null,
init: typeof bootInit === "function" ? bootInit : null,
splitPathSegments: splitPathSegments,
parseRoutePattern: parseRoutePattern,
@@ -4514,7 +4530,18 @@ callExpr.push(dictGet(kwargs, k)); } }
// --- Auto-init ---
if (typeof document !== "undefined") {
- var _sxInit = function() { bootInit(); };
+ var _sxInit = function() {
+ bootInit();
+ // Process any suspense resolutions that arrived before init
+ if (global.__sxPending) {
+ for (var pi = 0; pi < global.__sxPending.length; pi++) {
+ resolveSuspense(global.__sxPending[pi].id, global.__sxPending[pi].sx);
+ }
+ global.__sxPending = null;
+ }
+ // Set up direct resolution for future chunks
+ global.__sxResolve = function(id, sx) { resolveSuspense(id, sx); };
+ };
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", _sxInit);
} else {
diff --git a/shared/static/scripts/sx.js b/shared/static/scripts/sx.js
index bc48950..49c7e83 100644
--- a/shared/static/scripts/sx.js
+++ b/shared/static/scripts/sx.js
@@ -1588,6 +1588,32 @@
isTruthy: isSxTruthy,
isNil: isNil,
+ /**
+ * Resolve a streaming suspense placeholder.
+ * Called by inline """
+
+_SX_STREAMING_BOOTSTRAP = """\
+"""
+
+
+def sx_page_streaming_parts(ctx: dict, page_sx: str, *,
+ meta_html: str = "") -> tuple[str, str]:
+ """Split the page into shell (before scripts) and tail (scripts).
+
+ Returns (shell, tail) where:
+ shell = everything up to and including the page SX mount script
+ tail = the suspense bootstrap + sx-browser.js + body.js scripts
+
+ For streaming, the caller yields shell first, then resolution chunks,
+ then tail to close the document.
+ """
+ from .jinja_bridge import components_for_page, css_classes_for_page
+ from .css_registry import lookup_rules, get_preamble, registry_loaded, store_css_hash
+
+ from quart import current_app as _ca
+ component_defs, component_hash = components_for_page(page_sx, service=_ca.name)
+
+ client_hash = _get_sx_comp_cookie()
+ if not _is_dev_mode() and client_hash and client_hash == component_hash:
+ component_defs = ""
+
+ sx_css = ""
+ sx_css_classes = ""
+ if registry_loaded():
+ classes = css_classes_for_page(page_sx, service=_ca.name)
+ classes.update(["bg-stone-50", "text-stone-900"])
+ rules = lookup_rules(classes)
+ sx_css = get_preamble() + rules
+ sx_css_classes = store_css_hash(classes)
+
+ asset_url = get_asset_url(ctx)
+ title = ctx.get("base_title", "Rose Ash")
+ csrf = _get_csrf_token()
+
+ if _is_dev_mode() and page_sx and page_sx.startswith("("):
+ from .parser import parse as _parse, serialize as _serialize
+ try:
+ page_sx = _serialize(_parse(page_sx), pretty=True)
+ except Exception:
+ pass
+
+ styles_hash = _get_style_dict_hash()
+ client_styles_hash = _get_sx_styles_cookie()
+ styles_json = "" if (not _is_dev_mode() and client_styles_hash == styles_hash) else _build_style_dict_json()
+
+ import logging
+ from quart import current_app
+ pages_sx = _build_pages_sx(current_app.name)
+
+ sx_js_hash = _script_hash("sx-browser.js")
+ body_js_hash = _script_hash("body.js")
+
+ # Shell: everything up to and including the page SX
+ shell = (
+ '\n\n
\n'
+ '\n'
+ '\n'
+ '\n'
+ '\n'
+ f'{_html_escape(title)}\n'
+ f'{meta_html}'
+ '\n'
+ f'\n'
+ f'\n'
+ f'\n'
+ '\n'
+ '\n'
+ '\n'
+ '\n'
+ '\n'
+ "\n"
+ "\n"
+ '\n'
+ '\n'
+ '\n'
+ f'\n'
+ f'\n'
+ f'\n'
+ f'\n'
+ )
+
+ # Tail: bootstrap suspense resolver + scripts + close
+ tail = (
+ _SX_STREAMING_BOOTSTRAP + '\n'
+ f'\n'
+ f'\n'
+ )
+
+ return shell, tail
+
+
+def sx_streaming_resolve_script(suspension_id: str, sx_source: str) -> str:
+ """Build a
+;; ---------------------------------------------------------------------------
+
+(defcomp ~suspense (&key id fallback &rest children)
+ (div :id (str "sx-suspense-" id)
+ :data-suspense id
+ :style "display:contents"
+ (if children children fallback)))
+
(defcomp ~error-page (&key title message image asset-url)
(~base-shell :title title :asset-url asset-url
(div :class "text-center p-8 max-w-lg mx-auto"
diff --git a/shared/sx/types.py b/shared/sx/types.py
index 380f2ce..ee31f4f 100644
--- a/shared/sx/types.py
+++ b/shared/sx/types.py
@@ -241,6 +241,8 @@ class PageDef:
filter_expr: Any
aside_expr: Any
menu_expr: Any
+ stream: bool = False # enable streaming response
+ fallback_expr: Any = None # fallback content while streaming
closure: dict[str, Any] = field(default_factory=dict)
def __repr__(self):
diff --git a/sx/sx/boundary.sx b/sx/sx/boundary.sx
index fb5dd2a..232ffad 100644
--- a/sx/sx/boundary.sx
+++ b/sx/sx/boundary.sx
@@ -69,3 +69,8 @@
:params (spec-name)
:returns "dict"
:service "sx")
+
+(define-page-helper "streaming-demo-data"
+ :params ()
+ :returns "dict"
+ :service "sx")
diff --git a/sx/sx/nav-data.sx b/sx/sx/nav-data.sx
index 631973b..c5f8091 100644
--- a/sx/sx/nav-data.sx
+++ b/sx/sx/nav-data.sx
@@ -120,7 +120,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"
diff --git a/sx/sx/plans.sx b/sx/sx/plans.sx
index ef9c8f7..0656062 100644
--- a/sx/sx/plans.sx
+++ b/sx/sx/plans.sx
@@ -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: " "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
diff --git a/sx/sx/streaming-demo.sx b/sx/sx/streaming-demo.sx
new file mode 100644
index 0000000..25d0f08
--- /dev/null
+++ b/sx/sx/streaming-demo.sx
@@ -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" "")
+ " 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 ""))
+ (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")))))
diff --git a/sx/sxc/pages/docs.sx b/sx/sxc/pages/docs.sx
index 23416c8..6fee3a7 100644
--- a/sx/sxc/pages/docs.sx
+++ b/sx/sxc/pages/docs.sx
@@ -457,6 +457,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/"
diff --git a/sx/sxc/pages/helpers.py b/sx/sxc/pages/helpers.py
index fc264df..8a25df9 100644
--- a/sx/sxc/pages/helpers.py
+++ b/sx/sxc/pages/helpers.py
@@ -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,