Phase 6: Streaming & Suspense — chunked HTML with suspense resolution

Server streams HTML shell with ~suspense placeholders immediately,
then sends resolution <script> chunks as async IO completes. Browser
renders loading skeletons instantly, replacing them with real content
as data arrives via __sxResolve().

- defpage :stream true opts pages into streaming response
- ~suspense component renders fallback with data-suspense attr
- resolve-suspense in boot.sx (spec) + bootstrapped to sx-browser.js
- __sxPending queue handles resolution before sx-browser.js loads
- execute_page_streaming() async generator with concurrent IO tasks
- Streaming demo page at /isomorphism/streaming with 1.5s simulated delay

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 17:34:10 +00:00
parent 85083a0fff
commit a05d642461
15 changed files with 586 additions and 38 deletions

View File

@@ -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 {

View File

@@ -1588,6 +1588,32 @@
isTruthy: isSxTruthy,
isNil: isNil,
/**
* Resolve a streaming suspense placeholder.
* Called by inline <script> tags that arrive during chunked transfer:
* __sxResolve("content", "(~article :title \"Hello\")")
*
* Finds the suspense wrapper by data-suspense attribute, renders the
* new SX content, and replaces the wrapper's children.
*/
resolveSuspense: function (id, sx) {
var el = document.querySelector('[data-suspense="' + id + '"]');
if (!el) {
console.warn("[sx] resolveSuspense: no element for id=" + id);
return;
}
try {
var node = Sx.render(sx);
el.textContent = "";
el.appendChild(node);
if (typeof SxEngine !== "undefined") SxEngine.process(el);
Sx.hydrate(el);
el.dispatchEvent(new CustomEvent("sx:resolved", { bubbles: true, detail: { id: id } }));
} catch (e) {
console.error("[sx] resolveSuspense error for id=" + id, e);
}
},
/**
* Mount a sx expression into a DOM element, replacing its contents.
* Sx.mount(el, '(~card :title "Hi")')
@@ -3164,6 +3190,15 @@
Sx.processScripts();
Sx.hydrate();
SxEngine.process();
// Process any streaming suspense resolutions that arrived before init
if (global.__sxPending) {
for (var pi = 0; pi < global.__sxPending.length; pi++) {
Sx.resolveSuspense(global.__sxPending[pi].id, global.__sxPending[pi].sx);
}
global.__sxPending = null;
}
// Replace bootstrap resolver with direct calls
global.__sxResolve = function (id, sx) { Sx.resolveSuspense(id, sx); };
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);

View File

@@ -983,6 +983,13 @@ def _sf_defpage(expr: list, env: dict) -> PageDef:
if isinstance(cache_result, dict):
cache = cache_result
# Stream — evaluate (it's a static boolean)
stream_val = slots.get("stream")
stream = False
if stream_val is not None:
stream_result = _trampoline(_eval(stream_val, env))
stream = bool(stream_result)
page = PageDef(
name=name_sym.name,
path=path,
@@ -994,6 +1001,8 @@ def _sf_defpage(expr: list, env: dict) -> PageDef:
filter_expr=slots.get("filter"),
aside_expr=slots.get("aside"),
menu_expr=slots.get("menu"),
stream=stream,
fallback_expr=slots.get("fallback"),
closure=dict(env),
)
env[f"page:{name_sym.name}"] = page

View File

@@ -711,11 +711,14 @@ def _build_pages_sx(service: str) -> str:
closure_parts.append(f":{k} {_sx_literal(v)}")
closure_sx = "{" + " ".join(closure_parts) + "}"
stream = "true" if page_def.stream else "false"
entry = (
"{:name " + _sx_literal(page_def.name)
+ " :path " + _sx_literal(page_def.path)
+ " :auth " + _sx_literal(auth)
+ " :has-data " + has_data
+ " :stream " + stream
+ " :io-deps " + io_deps_sx
+ " :content " + _sx_literal(content_src)
+ " :deps " + deps_sx
@@ -826,6 +829,127 @@ def sx_page(ctx: dict, page_sx: str, *,
)
_SX_STREAMING_RESOLVE = """\
<script>window.__sxResolve&&window.__sxResolve({id},{sx})</script>"""
_SX_STREAMING_BOOTSTRAP = """\
<script>window.__sxPending=[];window.__sxResolve=function(i,s){\
if(window.Sx&&Sx.resolveSuspense){Sx.resolveSuspense(i,s)}\
else{window.__sxPending.push({id:i,sx:s})}}</script>"""
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 = (
'<!doctype html>\n<html lang="en">\n<head>\n'
'<meta charset="utf-8">\n'
'<meta name="viewport" content="width=device-width, initial-scale=1">\n'
'<meta name="robots" content="index,follow">\n'
'<meta name="theme-color" content="#ffffff">\n'
f'<title>{_html_escape(title)}</title>\n'
f'{meta_html}'
'<style>@media (min-width: 768px) { .js-mobile-sentinel { display:none !important; } }</style>\n'
f'<meta name="csrf-token" content="{_html_escape(csrf)}">\n'
f'<style id="sx-css">{sx_css}</style>\n'
f'<meta name="sx-css-classes" content="{sx_css_classes}">\n'
'<script src="https://unpkg.com/prismjs/prism.js"></script>\n'
'<script src="https://unpkg.com/prismjs/components/prism-javascript.min.js"></script>\n'
'<script src="https://unpkg.com/prismjs/components/prism-python.min.js"></script>\n'
'<script src="https://unpkg.com/prismjs/components/prism-bash.min.js"></script>\n'
'<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>\n'
"<script>if(matchMedia('(hover:hover) and (pointer:fine)').matches){document.documentElement.classList.add('hover-capable')}</script>\n"
"<script>document.addEventListener('click',function(e){var t=e.target.closest('[data-close-details]');if(!t)return;var d=t.closest('details');if(d)d.removeAttribute('open')})</script>\n"
'<style>\n'
'details[data-toggle-group="mobile-panels"]>summary{list-style:none}\n'
'details[data-toggle-group="mobile-panels"]>summary::-webkit-details-marker{display:none}\n'
'@media(min-width:768px){.nav-group:focus-within .submenu,.nav-group:hover .submenu{display:block}}\n'
'img{max-width:100%;height:auto}\n'
'.clamp-2{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}\n'
'.clamp-3{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}\n'
'.no-scrollbar::-webkit-scrollbar{display:none}.no-scrollbar{-ms-overflow-style:none;scrollbar-width:none}\n'
'details.group{overflow:hidden}details.group>summary{list-style:none}details.group>summary::-webkit-details-marker{display:none}\n'
'.sx-indicator{display:none}.sx-request .sx-indicator{display:inline-flex}\n'
'.sx-error .sx-indicator{display:none}.sx-loading .sx-indicator{display:inline-flex}\n'
'.js-wrap.open .js-pop{display:block}.js-wrap.open .js-backdrop{display:block}\n'
'</style>\n'
'</head>\n'
'<body class="bg-stone-50 text-stone-900">\n'
f'<script type="text/sx-styles" data-hash="{styles_hash}">{styles_json}</script>\n'
f'<script type="text/sx" data-components data-hash="{component_hash}">{component_defs}</script>\n'
f'<script type="text/sx-pages">{pages_sx}</script>\n'
f'<script type="text/sx" data-mount="body">{page_sx}</script>\n'
)
# Tail: bootstrap suspense resolver + scripts + close
tail = (
_SX_STREAMING_BOOTSTRAP + '\n'
f'<script src="{asset_url}/scripts/sx-browser.js?v={sx_js_hash}"></script>\n'
f'<script src="{asset_url}/scripts/body.js?v={body_js_hash}"></script>\n'
)
return shell, tail
def sx_streaming_resolve_script(suspension_id: str, sx_source: str) -> str:
"""Build a <script> tag that resolves a streaming suspense placeholder."""
import json
return _SX_STREAMING_RESOLVE.format(
id=json.dumps(suspension_id),
sx=json.dumps(sx_source),
)
_SCRIPT_HASH_CACHE: dict[str, str] = {}
_STYLE_DICT_JSON: str = ""
_STYLE_DICT_HASH: str = ""

View File

@@ -309,6 +309,157 @@ async def execute_page(
)
# ---------------------------------------------------------------------------
# Streaming page execution (Phase 6: Streaming & Suspense)
# ---------------------------------------------------------------------------
async def execute_page_streaming(
page_def: PageDef,
service_name: str,
url_params: dict[str, Any] | None = None,
):
"""Execute a page with streaming response.
Returns an async generator that yields HTML chunks:
1. HTML shell with suspense placeholders (immediate)
2. Resolution <script> tags as IO completes
3. Closing </body></html>
Each suspense placeholder renders a loading skeleton. As data and
header IO resolve, the server streams inline scripts that call
``__sxResolve(id, sx)`` to replace the placeholder content.
"""
import asyncio
from .jinja_bridge import get_component_env, _get_request_context
from .async_eval import async_eval
from .page import get_template_context
from .helpers import (
_render_to_sx, sx_page_streaming_parts,
sx_streaming_resolve_script,
)
from .parser import SxExpr, serialize as sx_serialize
from .layouts import get_layout
if url_params is None:
url_params = {}
env = dict(get_component_env())
env.update(get_page_helpers(service_name))
env.update(page_def.closure)
for key, val in url_params.items():
kebab = key.replace("_", "-")
env[kebab] = val
env[key] = val
ctx = _get_request_context()
tctx = await get_template_context()
# Build fallback expressions
if page_def.fallback_expr is not None:
fallback_sx = sx_serialize(page_def.fallback_expr)
else:
fallback_sx = (
'(div :class "p-8 animate-pulse"'
' (div :class "h-8 bg-stone-200 rounded mb-4 w-1/3")'
' (div :class "h-64 bg-stone-200 rounded"))'
)
header_fallback = '(div :class "h-12 bg-stone-200 animate-pulse")'
# Resolve layout
layout = None
layout_kwargs: dict[str, Any] = {}
if page_def.layout is not None:
if isinstance(page_def.layout, str):
layout_name = page_def.layout
elif isinstance(page_def.layout, list):
from .types import Keyword as SxKeyword, Symbol as SxSymbol
raw = page_def.layout
first = raw[0]
layout_name = (
first.name if isinstance(first, (SxKeyword, SxSymbol))
else str(first)
)
i = 1
while i < len(raw):
k = raw[i]
if isinstance(k, SxKeyword) and i + 1 < len(raw):
resolved = await async_eval(raw[i + 1], env, ctx)
layout_kwargs[k.name.replace("-", "_")] = resolved
i += 2
else:
i += 1
else:
layout_name = str(page_def.layout)
layout = get_layout(layout_name)
# --- Concurrent IO tasks ---
async def _eval_data_and_content():
data_env = dict(env)
if page_def.data_expr is not None:
data_result = await async_eval(page_def.data_expr, data_env, ctx)
if isinstance(data_result, dict):
for k, v in data_result.items():
data_env[k.replace("_", "-")] = v
content_sx = await _eval_slot(page_def.content_expr, data_env, ctx) if page_def.content_expr else ""
filter_sx = await _eval_slot(page_def.filter_expr, data_env, ctx) if page_def.filter_expr else ""
aside_sx = await _eval_slot(page_def.aside_expr, data_env, ctx) if page_def.aside_expr else ""
menu_sx = await _eval_slot(page_def.menu_expr, data_env, ctx) if page_def.menu_expr else ""
return content_sx, filter_sx, aside_sx, menu_sx
async def _eval_headers():
if layout is None:
return "", ""
rows = await layout.full_headers(tctx, **layout_kwargs)
menu = await layout.mobile_menu(tctx, **layout_kwargs)
return rows, menu
data_task = asyncio.create_task(_eval_data_and_content())
header_task = asyncio.create_task(_eval_headers())
# --- Build initial page SX with suspense placeholders ---
initial_page_sx = await _render_to_sx("app-body",
header_rows=SxExpr(
f'(~suspense :id "stream-headers" :fallback {header_fallback})'
),
content=SxExpr(
f'(~suspense :id "stream-content" :fallback {fallback_sx})'
),
)
shell, tail = sx_page_streaming_parts(tctx, initial_page_sx)
# --- Yield initial shell + scripts ---
yield shell + tail
# --- Yield resolution chunks in completion order ---
tasks = {data_task: "data", header_task: "headers"}
pending = set(tasks.keys())
while pending:
done, pending = await asyncio.wait(
pending, return_when=asyncio.FIRST_COMPLETED,
)
for task in done:
label = tasks[task]
try:
result = task.result()
except Exception as e:
logger.error("Streaming %s task failed: %s", label, e)
continue
if label == "data":
content_sx, filter_sx, aside_sx, menu_sx = result
yield sx_streaming_resolve_script("stream-content", content_sx)
elif label == "headers":
header_rows, header_menu = result
if header_rows:
yield sx_streaming_resolve_script("stream-headers", header_rows)
yield "\n</body>\n</html>"
# ---------------------------------------------------------------------------
# Blueprint mounting
# ---------------------------------------------------------------------------
@@ -356,17 +507,30 @@ def mount_pages(bp: Any, service_name: str,
def _mount_one_page(bp: Any, service_name: str, page_def: PageDef) -> None:
"""Mount a single PageDef as a GET route on the blueprint."""
from quart import make_response
from quart import make_response, Response
# Build the view function
async def page_view(**kwargs: Any) -> Any:
# Re-fetch the page from registry to support hot-reload of content
current = get_page(service_name, page_def.name) or page_def
result = await execute_page(current, service_name, url_params=kwargs)
# If result is already a Response (from sx_response), return it
if hasattr(result, "status_code"):
return result
return await make_response(result, 200)
if page_def.stream:
# Streaming response: yields HTML chunks as IO resolves
async def page_view(**kwargs: Any) -> Any:
from shared.browser.app.utils.htmx import is_htmx_request
current = get_page(service_name, page_def.name) or page_def
# Only stream for full page loads (not SX/HTMX requests)
if is_htmx_request():
result = await execute_page(current, service_name, url_params=kwargs)
if hasattr(result, "status_code"):
return result
return await make_response(result, 200)
# Streaming response
gen = execute_page_streaming(current, service_name, url_params=kwargs)
return Response(gen, content_type="text/html; charset=utf-8")
else:
# Standard non-streaming response
async def page_view(**kwargs: Any) -> Any:
current = get_page(service_name, page_def.name) or page_def
result = await execute_page(current, service_name, url_params=kwargs)
if hasattr(result, "status_code"):
return result
return await make_response(result, 200)
# Give the view function a unique name for Quart's routing
page_view.__name__ = f"defpage_{page_def.name.replace('-', '_')}"

View File

@@ -91,6 +91,32 @@
(sx-hydrate-elements el))))))
;; --------------------------------------------------------------------------
;; Resolve Suspense — replace streaming placeholder with resolved content
;; --------------------------------------------------------------------------
;;
;; Called by inline <script> tags that arrive during chunked transfer:
;; __sxResolve("content", "(~article :title \"Hello\")")
;;
;; Finds the suspense wrapper by data-suspense attribute, renders the
;; new SX content, and replaces the wrapper's children.
(define resolve-suspense
(fn (id sx)
(let ((el (dom-query (str "[data-suspense=\"" id "\"]"))))
(if el
(do
(let ((ast (parse sx))
(env (get-render-env nil))
(node (render-to-dom ast env nil)))
(dom-set-text-content el "")
(dom-append el node)
(process-elements el)
(sx-hydrate-elements el)
(dom-dispatch el "sx:resolved" {:id id})))
(log-warn (str "resolveSuspense: no element for id=" id))))))
;; --------------------------------------------------------------------------
;; Hydrate — render all [data-sx] elements
;; --------------------------------------------------------------------------

View File

@@ -480,6 +480,7 @@ class JSEmitter:
"init-style-dict": "initStyleDict",
"SX_VERSION": "SX_VERSION",
"boot-init": "bootInit",
"resolve-suspense": "resolveSuspense",
"resolve-mount-target": "resolveMountTarget",
"sx-render-with-env": "sxRenderWithEnv",
"get-render-env": "getRenderEnv",
@@ -4020,6 +4021,7 @@ def public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_cssx, has
api_lines.append(' update: typeof sxUpdateElement === "function" ? sxUpdateElement : null,')
api_lines.append(' renderComponent: typeof sxRenderComponent === "function" ? sxRenderComponent : null,')
api_lines.append(' getEnv: function() { return componentEnv; },')
api_lines.append(' resolveSuspense: typeof resolveSuspense === "function" ? resolveSuspense : null,')
api_lines.append(' init: typeof bootInit === "function" ? bootInit : null,')
elif has_orch:
api_lines.append(' init: typeof engineInit === "function" ? engineInit : null,')
@@ -4060,7 +4062,18 @@ def public_api_js(has_html, has_sx, has_dom, has_engine, has_orch, has_cssx, has
api_lines.append('''
// --- 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 {

View File

@@ -15,6 +15,20 @@
(body :class "bg-stone-50 text-stone-900"
children))))
;; ---------------------------------------------------------------------------
;; Suspense — streaming placeholder that renders fallback until resolved.
;;
;; Server-side: rendered in the initial streaming chunk with a fallback.
;; Client-side: replaced when the server streams a resolution chunk via
;; <script>__sxResolve("id", "(resolved sx ...)")</script>
;; ---------------------------------------------------------------------------
(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"

View File

@@ -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):