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:
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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('-', '_')}"
|
||||
|
||||
@@ -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
|
||||
;; --------------------------------------------------------------------------
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -69,3 +69,8 @@
|
||||
:params (spec-name)
|
||||
:returns "dict"
|
||||
:service "sx")
|
||||
|
||||
(define-page-helper "streaming-demo-data"
|
||||
:params ()
|
||||
:returns "dict"
|
||||
:service "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"
|
||||
|
||||
@@ -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
60
sx/sx/streaming-demo.sx
Normal 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")))))
|
||||
@@ -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/<slug>"
|
||||
|
||||
@@ -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"},
|
||||
],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user