Send content expression component deps in SX responses for client routing

When a page has a content expression but no data dependency, compute its
transitive component deps and pass them as extra_component_names to
sx_response(). This ensures the client has all component definitions
needed for future client-side route rendering.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-07 08:35:20 +00:00
parent 093050059d
commit 6215d3573b
2 changed files with 32 additions and 4 deletions

View File

@@ -456,7 +456,8 @@ def sx_call(component_name: str, **kwargs: Any) -> str:
def components_for_request(source: str = "") -> str: def components_for_request(source: str = "",
extra_names: set[str] | None = None) -> str:
"""Return defcomp/defmacro source for definitions the client doesn't have yet. """Return defcomp/defmacro source for definitions the client doesn't have yet.
Reads the ``SX-Components`` header (comma-separated component names Reads the ``SX-Components`` header (comma-separated component names
@@ -464,6 +465,10 @@ def components_for_request(source: str = "") -> str:
is missing. If *source* is provided, only sends components needed is missing. If *source* is provided, only sends components needed
for that source (plus transitive deps). If the header is absent, for that source (plus transitive deps). If the header is absent,
returns all needed defs. returns all needed defs.
*extra_names* — additional component names (``~foo``) to include
beyond what *source* references. Used by ``execute_page`` to send
components the page's content expression needs for client-side routing.
""" """
from quart import request from quart import request
from .jinja_bridge import _COMPONENT_ENV from .jinja_bridge import _COMPONENT_ENV
@@ -477,6 +482,12 @@ def components_for_request(source: str = "") -> str:
else: else:
needed = None # all needed = None # all
# Merge in extra names (e.g. from page content expression deps)
if extra_names and needed is not None:
needed = needed | extra_names
elif extra_names:
needed = extra_names
loaded_raw = request.headers.get("SX-Components", "") loaded_raw = request.headers.get("SX-Components", "")
loaded = set(loaded_raw.split(",")) if loaded_raw else set() loaded = set(loaded_raw.split(",")) if loaded_raw else set()
@@ -510,7 +521,8 @@ def components_for_request(source: str = "") -> str:
def sx_response(source: str, status: int = 200, def sx_response(source: str, status: int = 200,
headers: dict | None = None): headers: dict | None = None,
extra_component_names: set[str] | None = None):
"""Return an s-expression wire-format response. """Return an s-expression wire-format response.
Takes a raw sx string:: Takes a raw sx string::
@@ -520,6 +532,10 @@ def sx_response(source: str, status: int = 200,
For SX requests, missing component definitions are prepended as a For SX requests, missing component definitions are prepended as a
``<script type="text/sx" data-components>`` block so the client ``<script type="text/sx" data-components>`` block so the client
can process them before rendering OOB content. can process them before rendering OOB content.
*extra_component_names* — additional component names to include beyond
what *source* references. Used by defpage to send components the page's
content expression needs for client-side routing.
""" """
from quart import request, Response from quart import request, Response
@@ -535,7 +551,7 @@ def sx_response(source: str, status: int = 200,
# For SX requests, prepend missing component definitions # For SX requests, prepend missing component definitions
comp_defs = "" comp_defs = ""
if request.headers.get("SX-Request"): if request.headers.get("SX-Request"):
comp_defs = components_for_request(source) comp_defs = components_for_request(source, extra_names=extra_component_names)
if comp_defs: if comp_defs:
body = (f'<script type="text/sx" data-components>' body = (f'<script type="text/sx" data-components>'
f'{comp_defs}</script>\n{body}') f'{comp_defs}</script>\n{body}')

View File

@@ -279,13 +279,25 @@ async def execute_page(
is_htmx = is_htmx_request() is_htmx = is_htmx_request()
if is_htmx: if is_htmx:
# Compute content expression deps so the server sends component
# definitions the client needs for future client-side routing
extra_deps: set[str] | None = None
if page_def.content_expr is not None and page_def.data_expr is None:
from .deps import components_needed
from .parser import serialize
try:
content_src = serialize(page_def.content_expr)
extra_deps = components_needed(content_src, get_component_env())
except Exception:
pass # non-critical — client will just fall back to server
return sx_response(await oob_page_sx( return sx_response(await oob_page_sx(
oobs=oob_headers if oob_headers else "", oobs=oob_headers if oob_headers else "",
filter=filter_sx, filter=filter_sx,
aside=aside_sx, aside=aside_sx,
content=content_sx, content=content_sx,
menu=menu_sx, menu=menu_sx,
)) ), extra_component_names=extra_deps)
else: else:
return await full_page_sx( return await full_page_sx(
tctx, tctx,