From 309579aec7453246bd919aca458742bb8b6adff6 Mon Sep 17 00:00:00 2001 From: giles Date: Sat, 7 Mar 2026 18:34:24 +0000 Subject: [PATCH] Fix streaming: render initial shell as HTML, not SX wire format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The streaming shell now uses render_to_html so [data-suspense] elements are real DOM elements immediately when the browser parses the HTML. Previously the shell used SX wire format in a """ -def sx_page_streaming_parts(ctx: dict, page_sx: str, *, +def sx_page_streaming_parts(ctx: dict, page_html: str, *, + 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 initial page is rendered to **HTML** server-side so + ``[data-suspense]`` elements are in the DOM immediately — no client-side + SX rendering needed for the shell. Resolution scripts can find and + replace suspense placeholders without waiting for sx-browser.js to boot. - For streaming, the caller yields shell first, then resolution chunks, - then tail to close the document. + Args: + page_html: Server-rendered HTML for the page body (with suspense + placeholders already as real HTML elements). + page_sx: SX source scanned for component deps (may differ from + page_html when components were expanded server-side). """ 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) + + # Scan the SX source for component deps (needed for resolution scripts + # that may contain component calls the client must render) + scan_source = page_sx or page_html + component_defs, component_hash = components_for_page(scan_source, service=_ca.name) client_hash = _get_sx_comp_cookie() if not _is_dev_mode() and client_hash and client_hash == component_hash: @@ -862,7 +871,7 @@ def sx_page_streaming_parts(ctx: dict, page_sx: str, *, sx_css = "" sx_css_classes = "" if registry_loaded(): - classes = css_classes_for_page(page_sx, service=_ca.name) + classes = css_classes_for_page(scan_source, service=_ca.name) classes.update(["bg-stone-50", "text-stone-900"]) rules = lookup_rules(classes) sx_css = get_preamble() + rules @@ -872,13 +881,6 @@ def sx_page_streaming_parts(ctx: dict, page_sx: str, *, 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() @@ -890,7 +892,7 @@ def sx_page_streaming_parts(ctx: dict, page_sx: str, *, 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: head + body with server-rendered HTML (not SX mount script) shell = ( '\n\n\n' '\n' @@ -928,7 +930,8 @@ def sx_page_streaming_parts(ctx: dict, page_sx: str, *, f'\n' f'\n' f'\n' - f'\n' + # Server-rendered HTML — suspense placeholders are real DOM elements + f'{page_html}\n' ) # Tail: bootstrap suspense resolver + scripts + close diff --git a/shared/sx/pages.py b/shared/sx/pages.py index f4f7ed7..98aa704 100644 --- a/shared/sx/pages.py +++ b/shared/sx/pages.py @@ -330,7 +330,8 @@ async def execute_page_streaming( from .async_eval import async_eval from .page import get_template_context from .helpers import ( - _render_to_sx, sx_page_streaming_parts, + render_to_html as _helpers_render_to_html, + sx_page_streaming_parts, sx_streaming_resolve_script, ) from .parser import SxExpr, serialize as sx_serialize @@ -413,18 +414,24 @@ async def execute_page_streaming( data_task = asyncio.create_task(_eval_data_and_content()) header_task = asyncio.create_task(_eval_headers()) - # --- Build initial shell (still in request context) --- + # --- Build initial shell as HTML (still in request context) --- + # Render to HTML so [data-suspense] elements are real DOM immediately. + # No dependency on sx-browser.js boot timing for the initial shell. - 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})' - ), + suspense_header_sx = f'(~suspense :id "stream-headers" :fallback {header_fallback})' + suspense_content_sx = f'(~suspense :id "stream-content" :fallback {fallback_sx})' + + initial_page_html = await _helpers_render_to_html("app-body", + header_rows=SxExpr(suspense_header_sx), + content=SxExpr(suspense_content_sx), ) - shell, tail = sx_page_streaming_parts(tctx, initial_page_sx) + # Pass the SX source for component scanning (resolution scripts may + # contain component calls that the client needs to render) + page_sx_for_scan = f'(~app-body :header-rows {suspense_header_sx} :content {suspense_content_sx})' + shell, tail = sx_page_streaming_parts( + tctx, initial_page_html, page_sx=page_sx_for_scan, + ) # --- Return async generator that yields chunks --- # No context access needed below — just awaiting tasks and yielding strings.