Fix streaming: render initial shell as HTML, not SX wire format

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 <script data-mount> tag,
requiring sx-browser.js to boot and render before suspense elements
existed — creating a race condition where resolution scripts fired
before the elements were in the DOM.

Now: server renders HTML with suspense placeholders → browser has real
DOM elements → resolution scripts find and replace them reliably.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 18:34:24 +00:00
parent 44095c0a04
commit 309579aec7
2 changed files with 37 additions and 27 deletions

View File

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