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:
@@ -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 = ""
|
||||
|
||||
Reference in New Issue
Block a user